From b155132acf7a897d1eb8cdb1d48b5a238f056d7d Mon Sep 17 00:00:00 2001 From: poyrazavsever Date: Thu, 16 Jul 2026 16:46:42 +0300 Subject: [PATCH] feat(domain): complete phase 2 backend core --- app/api/portal-invitations/route.ts | 9 +- .../neta-self-hosted-v3-master-plan.md | 53 +- docs/self-hosted-redesign/phase-2-auth.md | 4 +- .../phase-2-domain-core.md | 94 + package.json | 1 + scripts/phase1-auth-smoke.mjs | 27 + scripts/phase2-domain-smoke.mjs | 19 + scripts/phase2-domain-smoke.ts | 162 + server/api/responses.ts | 30 + server/auth/domain-actor.ts | 13 + server/auth/invitations.ts | 51 +- server/auth/session.ts | 27 +- server/db/migrations/0003_chief_excalibur.sql | 322 ++ server/db/migrations/meta/0003_snapshot.json | 3131 +++++++++++++++++ server/db/migrations/meta/_journal.json | 7 + server/db/schema/auth.ts | 2 +- server/db/schema/domain.ts | 474 +++ server/db/schema/index.ts | 1 + server/domain/actor.ts | 46 + server/domain/database.ts | 4 + server/domain/errors.ts | 38 + server/domain/id.ts | 5 + server/domain/types.ts | 50 + server/domain/validation.ts | 230 ++ server/repositories/domain.ts | 138 + server/services/domain.ts | 405 +++ server/services/runtime.ts | 8 + tsconfig.phase2-smoke.json | 22 + 28 files changed, 5343 insertions(+), 30 deletions(-) create mode 100644 docs/self-hosted-redesign/phase-2-domain-core.md create mode 100644 scripts/phase2-domain-smoke.mjs create mode 100644 scripts/phase2-domain-smoke.ts create mode 100644 server/api/responses.ts create mode 100644 server/auth/domain-actor.ts create mode 100644 server/db/migrations/0003_chief_excalibur.sql create mode 100644 server/db/migrations/meta/0003_snapshot.json create mode 100644 server/db/schema/domain.ts create mode 100644 server/domain/actor.ts create mode 100644 server/domain/database.ts create mode 100644 server/domain/errors.ts create mode 100644 server/domain/id.ts create mode 100644 server/domain/types.ts create mode 100644 server/domain/validation.ts create mode 100644 server/repositories/domain.ts create mode 100644 server/services/domain.ts create mode 100644 server/services/runtime.ts create mode 100644 tsconfig.phase2-smoke.json diff --git a/app/api/portal-invitations/route.ts b/app/api/portal-invitations/route.ts index 47658a6..2c825cd 100644 --- a/app/api/portal-invitations/route.ts +++ b/app/api/portal-invitations/route.ts @@ -31,7 +31,14 @@ export async function POST(request: Request) { function invitationErrorResponse(error: unknown) { 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 }); } diff --git a/docs/self-hosted-redesign/neta-self-hosted-v3-master-plan.md b/docs/self-hosted-redesign/neta-self-hosted-v3-master-plan.md index cf58c5c..c89a530 100644 --- a/docs/self-hosted-redesign/neta-self-hosted-v3-master-plan.md +++ b/docs/self-hosted-redesign/neta-self-hosted-v3-master-plan.md @@ -248,7 +248,7 @@ Checklist: - [x] Davet iptal edilebiliyor. - [x] Aynı müşteri için aktif davet politikası tanımlandı. - [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] Client hesabı disable/revoke edilebiliyor. @@ -567,14 +567,14 @@ Transaction içinde doğrulanacaklar: Checklist: -- [ ] `project_id` ve `client_id` eşleşmesi server-side doğrulanıyor. -- [ ] İstemciden gelen `clientId` güven kaynağı olarak kullanılmıyor. -- [ ] Quota server-side kontrol ediliyor. -- [ ] Quota atomik azaltılıyor veya tüketim kayıtlarından hesaplanıyor. -- [ ] Başarısız insert quota tüketmiyor. -- [ ] Cross-project revision negatif testi var. -- [ ] Başka client adına revision oluşturma negatif testi var. -- [ ] Kota aşımı negatif testi var. +- [x] `project_id` ve `client_id` eşleşmesi server-side doğrulanıyor. +- [x] İstemciden gelen `clientId` güven kaynağı olarak kullanılmıyor. +- [x] Quota server-side kontrol ediliyor. +- [x] Quota atomik azaltılıyor veya tüketim kayıtlarından hesaplanıyor. +- [x] Başarısız insert quota tüketmiyor. +- [x] Cross-project revision negatif testi var. +- [x] Başka client adına revision oluşturma negatif testi var. +- [x] Kota aşımı negatif testi var. ## 16. API v1 ve mobil hazırlığı @@ -600,13 +600,13 @@ DELETE /api/v1/device-sessions/:id Mobil hazırlık checklist'i: -- [ ] API response envelope standardı tanımlandı. -- [ ] API hata kodları tanımlandı. +- [x] API response envelope standardı tanımlandı. +- [x] API hata kodları tanımlandı. - [ ] API sürümleme stratejisi tanımlandı. - [ ] Instance metadata sözleşmesi tanımlandı. - [ ] Minimum desteklenen client sürümü alanı 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. - [ ] 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] Expired/revoked invitation negatif testi - [ ] Her repository için cross-owner negatif test -- [ ] Client private task erişim negatif testi -- [ ] Revision project-client eşleşme negatif testi -- [ ] Revision quota testi +- [x] Client private task erişim negatif testi +- [x] Revision project-client eşleşme negatif testi +- [x] Revision quota testi - [ ] File upload MIME/size testi - [ ] Path traversal negatif 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. -- [ ] Çekirdek tablolar oluşturuldu. -- [ ] Repository katmanı oluşturuldu. -- [ ] Service katmanı oluşturuldu. -- [ ] Actor/authorization sözleşmesi standartlaştırıldı. -- [ ] Validation ve error sözleşmesi standartlaştırıldı. -- [ ] Negatif authorization testleri yazıldı. -- [ ] Analytics aggregate sorgu yaklaşımı belirlendi. +- [x] Çekirdek tablolar oluşturuldu. +- [x] Repository katmanı oluşturuldu. +- [x] Service katmanı oluşturuldu. +- [x] Actor/authorization sözleşmesi standartlaştırıldı. +- [x] Validation ve error sözleşmesi standartlaştırıldı. +- [x] Negatif authorization testleri yazıldı. +- [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. diff --git a/docs/self-hosted-redesign/phase-2-auth.md b/docs/self-hosted-redesign/phase-2-auth.md index e7de0f5..a011bd9 100644 --- a/docs/self-hosted-redesign/phase-2-auth.md +++ b/docs/self-hosted-redesign/phase-2-auth.md @@ -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. - 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. -- 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. - 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. -`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ı diff --git a/docs/self-hosted-redesign/phase-2-domain-core.md b/docs/self-hosted-redesign/phase-2-domain-core.md new file mode 100644 index 0000000..bf49471 --- /dev/null +++ b/docs/self-hosted-redesign/phase-2-domain-core.md @@ -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. diff --git a/package.json b/package.json index 4e9c653..af9824f 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "db:restore": "node scripts/restore.mjs", "phase1:smoke": "node scripts/phase1-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", "phase3:ui-boundary": "node scripts/phase3-ui-boundary.mjs" }, diff --git a/scripts/phase1-auth-smoke.mjs b/scripts/phase1-auth-smoke.mjs index b73bc57..7a895b8 100644 --- a/scripts/phase1-auth-smoke.mjs +++ b/scripts/phase1-auth-smoke.mjs @@ -78,6 +78,20 @@ try { "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", { name: "Public Attacker", email: "attacker@example.com", @@ -110,6 +124,13 @@ try { }); 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", { method: "POST", cookie: ownerCookie, @@ -150,6 +171,12 @@ try { .get("client@example.com"), { 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( db.prepare("select password from account where user_id = ?").get(clientAuthUserId).password, "Client-Password-123", diff --git a/scripts/phase2-domain-smoke.mjs b/scripts/phase2-domain-smoke.mjs new file mode 100644 index 0000000..b0ed7a1 --- /dev/null +++ b/scripts/phase2-domain-smoke.mjs @@ -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" }, +); diff --git a/scripts/phase2-domain-smoke.ts b/scripts/phase2-domain-smoke.ts new file mode 100644 index 0000000..9b62f59 --- /dev/null +++ b/scripts/phase2-domain-smoke.ts @@ -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); +} diff --git a/server/api/responses.ts b/server/api/responses.ts new file mode 100644 index 0000000..4f6dac5 --- /dev/null +++ b/server/api/responses.ts @@ -0,0 +1,30 @@ +import "server-only"; + +import { NextResponse } from "next/server"; +import { DomainError } from "../domain/errors"; + +export function apiSuccess(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 }, + ); +} diff --git a/server/auth/domain-actor.ts b/server/auth/domain-actor.ts new file mode 100644 index 0000000..f000786 --- /dev/null +++ b/server/auth/domain-actor.ts @@ -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, + }; +} diff --git a/server/auth/invitations.ts b/server/auth/invitations.ts index faf62e5..be569a9 100644 --- a/server/auth/invitations.ts +++ b/server/auth/invitations.ts @@ -2,7 +2,7 @@ import "server-only"; import { createHash, randomBytes, randomUUID } from "node:crypto"; import { hashPassword } from "better-auth/crypto"; -import { and, eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { z } from "zod"; import type { SessionContext } from "@/server/auth/session"; import { getServerConfig } from "@/server/config"; @@ -11,6 +11,7 @@ import { account, appProfiles, authAuditEvents, + clients, portalInvitations, session, user, @@ -37,6 +38,7 @@ export type PortalInvitationErrorCode = | "INVITATION_NOT_FOUND" | "INVITATION_NOT_PENDING" | "INVITATION_EXPIRED" + | "CLIENT_NOT_FOUND" | "CLIENT_ALREADY_LINKED" | "EMAIL_ALREADY_REGISTERED"; @@ -70,6 +72,25 @@ export async function createPortalInvitation( const { db } = getSqliteConnection(); const invitationId = db.transaction((tx) => { + const client = tx + .select({ id: clients.id, authUserId: clients.authUserId }) + .from(clients) + .where( + and(eq(clients.id, parsed.clientId), eq(clients.ownerUserId, actor.user.id)), + ) + .get(); + + if (!client) { + throw new PortalInvitationError("CLIENT_NOT_FOUND", "Müşteri bulunamadı."); + } + + if (client.authUserId) { + throw new PortalInvitationError( + "CLIENT_ALREADY_LINKED", + "Bu müşteri için portal hesabı zaten mevcut.", + ); + } + const [linkedProfile] = tx .select({ id: appProfiles.id }) .from(appProfiles) @@ -333,6 +354,24 @@ export async function acceptPortalInvitation(input: { }) .run(); + const linkedClient = tx + .update(clients) + .set({ authUserId, updatedAt: now }) + .where( + and( + eq(clients.id, invitation.clientId), + isNull(clients.authUserId), + ), + ) + .run(); + + if (linkedClient.changes !== 1) { + throw new PortalInvitationError( + "CLIENT_ALREADY_LINKED", + "Müşteri kaydı bulunamadı veya başka bir hesaba bağlandı.", + ); + } + const accepted = tx .update(portalInvitations) .set({ status: "accepted", acceptedAt: now }) @@ -417,6 +456,16 @@ export function setClientPortalAccess( const { db } = getSqliteConnection(); db.transaction((tx) => { + const ownedClient = tx + .select({ id: clients.id }) + .from(clients) + .where(and(eq(clients.id, clientId), eq(clients.ownerUserId, actor.user.id))) + .get(); + + if (!ownedClient) { + throw new PortalInvitationError("CLIENT_NOT_FOUND", "Müşteri bulunamadı."); + } + const [profile] = tx .select({ authUserId: appProfiles.authUserId, email: appProfiles.email }) .from(appProfiles) diff --git a/server/auth/session.ts b/server/auth/session.ts index 3c9415a..7b57438 100644 --- a/server/auth/session.ts +++ b/server/auth/session.ts @@ -1,13 +1,13 @@ import "server-only"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { cache } from "react"; import { auth } from "@/server/auth/auth"; import type { UserRole } from "@/server/auth/types"; import { getSqliteConnection } from "@/server/db/client"; -import { appProfiles } from "@/server/db/schema"; +import { appProfiles, clients } from "@/server/db/schema"; type BetterAuthSession = NonNullable>>; @@ -108,5 +108,26 @@ export function getProfileByAuthUserId(authUserId: string): SessionContext["prof .limit(1) .all(); - return profile ?? null; + if (!profile) { + return null; + } + + if (profile.role === "client") { + if (!profile.clientId) return null; + + const linkedClient = db + .select({ id: clients.id }) + .from(clients) + .where( + and( + eq(clients.id, profile.clientId), + eq(clients.authUserId, profile.authUserId), + ), + ) + .get(); + + if (!linkedClient) return null; + } + + return profile; } diff --git a/server/db/migrations/0003_chief_excalibur.sql b/server/db/migrations/0003_chief_excalibur.sql new file mode 100644 index 0000000..f549c68 --- /dev/null +++ b/server/db/migrations/0003_chief_excalibur.sql @@ -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`); \ No newline at end of file diff --git a/server/db/migrations/meta/0003_snapshot.json b/server/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..513d993 --- /dev/null +++ b/server/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,3131 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "77654cef-e8eb-4b90-8fce-ec23b067c348", + "prevId": "9dcf0ef9-1e97-48d0-87ac-a5da54e16bc5", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_profiles": { + "name": "app_profiles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "auth_user_id": { + "name": "auth_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disabled": { + "name": "disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "app_profiles_auth_user_id_unique": { + "name": "app_profiles_auth_user_id_unique", + "columns": [ + "auth_user_id" + ], + "isUnique": true + }, + "app_profiles_client_id_unique": { + "name": "app_profiles_client_id_unique", + "columns": [ + "client_id" + ], + "isUnique": true + }, + "app_profiles_role_idx": { + "name": "app_profiles_role_idx", + "columns": [ + "role" + ], + "isUnique": false + } + }, + "foreignKeys": { + "app_profiles_auth_user_id_user_id_fk": { + "name": "app_profiles_auth_user_id_user_id_fk", + "tableFrom": "app_profiles", + "tableTo": "user", + "columnsFrom": [ + "auth_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_setup_state": { + "name": "app_setup_state", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_by": { + "name": "locked_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locked_at": { + "name": "locked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_audit_events": { + "name": "auth_audit_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_user_id": { + "name": "auth_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "auth_audit_events_type_idx": { + "name": "auth_audit_events_type_idx", + "columns": [ + "type" + ], + "isUnique": false + }, + "auth_audit_events_auth_user_id_idx": { + "name": "auth_audit_events_auth_user_id_idx", + "columns": [ + "auth_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "auth_audit_events_auth_user_id_user_id_fk": { + "name": "auth_audit_events_auth_user_id_user_id_fk", + "tableFrom": "auth_audit_events", + "tableTo": "user", + "columnsFrom": [ + "auth_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "portal_invitations": { + "name": "portal_invitations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "portal_invitations_token_hash_unique": { + "name": "portal_invitations_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + }, + "portal_invitations_client_id_idx": { + "name": "portal_invitations_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "portal_invitations_email_idx": { + "name": "portal_invitations_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "portal_invitations_created_by_user_id_user_id_fk": { + "name": "portal_invitations_created_by_user_id_user_id_fk", + "tableFrom": "portal_invitations", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "calendar_events": { + "name": "calendar_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'focus'" + }, + "starts_at": { + "name": "starts_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "calendar_events_owner_range_idx": { + "name": "calendar_events_owner_range_idx", + "columns": [ + "owner_user_id", + "starts_at" + ], + "isUnique": false + }, + "calendar_events_project_id_idx": { + "name": "calendar_events_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "calendar_events_task_id_idx": { + "name": "calendar_events_task_id_idx", + "columns": [ + "task_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "calendar_events_owner_user_id_user_id_fk": { + "name": "calendar_events_owner_user_id_user_id_fk", + "tableFrom": "calendar_events", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "calendar_events_client_id_clients_id_fk": { + "name": "calendar_events_client_id_clients_id_fk", + "tableFrom": "calendar_events", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "calendar_events_project_id_projects_id_fk": { + "name": "calendar_events_project_id_projects_id_fk", + "tableFrom": "calendar_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "calendar_events_task_id_tasks_id_fk": { + "name": "calendar_events_task_id_tasks_id_fk", + "tableFrom": "calendar_events", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "calendar_events_type_check": { + "name": "calendar_events_type_check", + "value": "\"calendar_events\".\"type\" in ('meeting', 'focus', 'deadline', 'personal', 'finance')" + }, + "calendar_events_time_check": { + "name": "calendar_events_time_check", + "value": "\"calendar_events\".\"ends_at\" is null or \"calendar_events\".\"ends_at\" >= \"calendar_events\".\"starts_at\"" + } + } + }, + "chat_messages": { + "name": "chat_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_journal_entry_ids": { + "name": "context_journal_entry_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "chat_messages_session_created_idx": { + "name": "chat_messages_session_created_idx", + "columns": [ + "session_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_messages_session_id_chat_sessions_id_fk": { + "name": "chat_messages_session_id_chat_sessions_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "chat_messages_role_check": { + "name": "chat_messages_role_check", + "value": "\"chat_messages\".\"role\" in ('system', 'user', 'assistant', 'tool')" + } + } + }, + "chat_sessions": { + "name": "chat_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "chat_sessions_owner_updated_idx": { + "name": "chat_sessions_owner_updated_idx", + "columns": [ + "owner_user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_sessions_owner_user_id_user_id_fk": { + "name": "chat_sessions_owner_user_id_user_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "client_activities": { + "name": "client_activities", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "activity_date": { + "name": "activity_date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "client_activities_owner_client_idx": { + "name": "client_activities_owner_client_idx", + "columns": [ + "owner_user_id", + "client_id" + ], + "isUnique": false + }, + "client_activities_client_date_idx": { + "name": "client_activities_client_date_idx", + "columns": [ + "client_id", + "activity_date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "client_activities_owner_user_id_user_id_fk": { + "name": "client_activities_owner_user_id_user_id_fk", + "tableFrom": "client_activities", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_activities_client_id_clients_id_fk": { + "name": "client_activities_client_id_clients_id_fk", + "tableFrom": "client_activities", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "client_activities_type_check": { + "name": "client_activities_type_check", + "value": "\"client_activities\".\"type\" in ('note', 'call', 'meeting', 'email')" + } + } + }, + "clients": { + "name": "clients", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_user_id": { + "name": "auth_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "pipeline_stage": { + "name": "pipeline_stage", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'lead'" + }, + "next_follow_up_date": { + "name": "next_follow_up_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "clients_auth_user_id_unique": { + "name": "clients_auth_user_id_unique", + "columns": [ + "auth_user_id" + ], + "isUnique": true + }, + "clients_owner_user_id_idx": { + "name": "clients_owner_user_id_idx", + "columns": [ + "owner_user_id" + ], + "isUnique": false + }, + "clients_owner_status_idx": { + "name": "clients_owner_status_idx", + "columns": [ + "owner_user_id", + "status" + ], + "isUnique": false + }, + "clients_owner_pipeline_idx": { + "name": "clients_owner_pipeline_idx", + "columns": [ + "owner_user_id", + "pipeline_stage" + ], + "isUnique": false + }, + "clients_next_follow_up_date_idx": { + "name": "clients_next_follow_up_date_idx", + "columns": [ + "next_follow_up_date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "clients_owner_user_id_user_id_fk": { + "name": "clients_owner_user_id_user_id_fk", + "tableFrom": "clients", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "clients_auth_user_id_user_id_fk": { + "name": "clients_auth_user_id_user_id_fk", + "tableFrom": "clients", + "tableTo": "user", + "columnsFrom": [ + "auth_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "clients_status_check": { + "name": "clients_status_check", + "value": "\"clients\".\"status\" in ('active', 'paused', 'archived')" + }, + "clients_pipeline_stage_check": { + "name": "clients_pipeline_stage_check", + "value": "\"clients\".\"pipeline_stage\" in ('lead', 'contacted', 'proposal_sent', 'won', 'lost')" + } + } + }, + "contracts": { + "name": "contracts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "proposal_id": { + "name": "proposal_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "signed_at": { + "name": "signed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "contracts_owner_status_idx": { + "name": "contracts_owner_status_idx", + "columns": [ + "owner_user_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "contracts_owner_user_id_user_id_fk": { + "name": "contracts_owner_user_id_user_id_fk", + "tableFrom": "contracts", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contracts_proposal_id_proposals_id_fk": { + "name": "contracts_proposal_id_proposals_id_fk", + "tableFrom": "contracts", + "tableTo": "proposals", + "columnsFrom": [ + "proposal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "contracts_client_id_clients_id_fk": { + "name": "contracts_client_id_clients_id_fk", + "tableFrom": "contracts", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "contracts_status_check": { + "name": "contracts_status_check", + "value": "\"contracts\".\"status\" in ('draft', 'active', 'completed', 'cancelled')" + } + } + }, + "finance_transactions": { + "name": "finance_transactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_minor": { + "name": "amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "transaction_date": { + "name": "transaction_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'planned'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "finance_transactions_owner_date_idx": { + "name": "finance_transactions_owner_date_idx", + "columns": [ + "owner_user_id", + "transaction_date" + ], + "isUnique": false + }, + "finance_transactions_owner_type_idx": { + "name": "finance_transactions_owner_type_idx", + "columns": [ + "owner_user_id", + "type" + ], + "isUnique": false + }, + "finance_transactions_client_id_idx": { + "name": "finance_transactions_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "finance_transactions_project_id_idx": { + "name": "finance_transactions_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "finance_transactions_owner_user_id_user_id_fk": { + "name": "finance_transactions_owner_user_id_user_id_fk", + "tableFrom": "finance_transactions", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "finance_transactions_client_id_clients_id_fk": { + "name": "finance_transactions_client_id_clients_id_fk", + "tableFrom": "finance_transactions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "finance_transactions_project_id_projects_id_fk": { + "name": "finance_transactions_project_id_projects_id_fk", + "tableFrom": "finance_transactions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "finance_transactions_type_check": { + "name": "finance_transactions_type_check", + "value": "\"finance_transactions\".\"type\" in ('income', 'expense')" + }, + "finance_transactions_amount_check": { + "name": "finance_transactions_amount_check", + "value": "\"finance_transactions\".\"amount_minor\" >= 0" + }, + "finance_transactions_payment_status_check": { + "name": "finance_transactions_payment_status_check", + "value": "\"finance_transactions\".\"payment_status\" in ('planned', 'pending', 'paid', 'cancelled')" + }, + "finance_transactions_currency_check": { + "name": "finance_transactions_currency_check", + "value": "length(\"finance_transactions\".\"currency\") = 3" + } + } + }, + "invoices": { + "name": "invoices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_minor": { + "name": "amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "tax_basis_points": { + "name": "tax_basis_points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'TRY'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "issue_date": { + "name": "issue_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_at": { + "name": "paid_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "invoices_owner_number_unique": { + "name": "invoices_owner_number_unique", + "columns": [ + "owner_user_id", + "invoice_number" + ], + "isUnique": true + }, + "invoices_owner_status_idx": { + "name": "invoices_owner_status_idx", + "columns": [ + "owner_user_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invoices_owner_user_id_user_id_fk": { + "name": "invoices_owner_user_id_user_id_fk", + "tableFrom": "invoices", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoices_client_id_clients_id_fk": { + "name": "invoices_client_id_clients_id_fk", + "tableFrom": "invoices", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "invoices_project_id_projects_id_fk": { + "name": "invoices_project_id_projects_id_fk", + "tableFrom": "invoices", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "invoices_status_check": { + "name": "invoices_status_check", + "value": "\"invoices\".\"status\" in ('draft', 'sent', 'paid', 'overdue', 'cancelled')" + }, + "invoices_amount_check": { + "name": "invoices_amount_check", + "value": "\"invoices\".\"amount_minor\" >= 0" + }, + "invoices_tax_check": { + "name": "invoices_tax_check", + "value": "\"invoices\".\"tax_basis_points\" between 0 and 10000" + } + } + }, + "journal_entries": { + "name": "journal_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entry_date": { + "name": "entry_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mood_score": { + "name": "mood_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "energy_score": { + "name": "energy_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "work_satisfaction_score": { + "name": "work_satisfaction_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mood_label": { + "name": "mood_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_ai_metadata": { + "name": "legacy_ai_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "journal_entries_owner_date_unique": { + "name": "journal_entries_owner_date_unique", + "columns": [ + "owner_user_id", + "entry_date" + ], + "isUnique": true + }, + "journal_entries_owner_date_idx": { + "name": "journal_entries_owner_date_idx", + "columns": [ + "owner_user_id", + "entry_date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "journal_entries_owner_user_id_user_id_fk": { + "name": "journal_entries_owner_user_id_user_id_fk", + "tableFrom": "journal_entries", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "journal_entries_mood_score_check": { + "name": "journal_entries_mood_score_check", + "value": "\"journal_entries\".\"mood_score\" is null or \"journal_entries\".\"mood_score\" between 1 and 5" + }, + "journal_entries_energy_score_check": { + "name": "journal_entries_energy_score_check", + "value": "\"journal_entries\".\"energy_score\" is null or \"journal_entries\".\"energy_score\" between 1 and 5" + }, + "journal_entries_work_score_check": { + "name": "journal_entries_work_score_check", + "value": "\"journal_entries\".\"work_satisfaction_score\" is null or \"journal_entries\".\"work_satisfaction_score\" between 1 and 5" + } + } + }, + "project_planning_sections": { + "name": "project_planning_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "project_planning_sections_owner_idx": { + "name": "project_planning_sections_owner_idx", + "columns": [ + "owner_user_id" + ], + "isUnique": false + }, + "project_planning_sections_project_order_idx": { + "name": "project_planning_sections_project_order_idx", + "columns": [ + "project_id", + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": { + "project_planning_sections_owner_user_id_user_id_fk": { + "name": "project_planning_sections_owner_user_id_user_id_fk", + "tableFrom": "project_planning_sections", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_planning_sections_project_id_projects_id_fk": { + "name": "project_planning_sections_project_id_projects_id_fk", + "tableFrom": "project_planning_sections", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_planning_sections_category_check": { + "name": "project_planning_sections_category_check", + "value": "\"project_planning_sections\".\"category\" in ('overview', 'problem', 'goal', 'audience', 'scope', 'design_system', 'color_palette', 'typography', 'assets', 'notes')" + } + } + }, + "project_revisions": { + "name": "project_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "project_revisions_owner_project_idx": { + "name": "project_revisions_owner_project_idx", + "columns": [ + "owner_user_id", + "project_id" + ], + "isUnique": false + }, + "project_revisions_client_project_idx": { + "name": "project_revisions_client_project_idx", + "columns": [ + "client_id", + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "project_revisions_owner_user_id_user_id_fk": { + "name": "project_revisions_owner_user_id_user_id_fk", + "tableFrom": "project_revisions", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_revisions_project_id_projects_id_fk": { + "name": "project_revisions_project_id_projects_id_fk", + "tableFrom": "project_revisions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_revisions_client_id_clients_id_fk": { + "name": "project_revisions_client_id_clients_id_fk", + "tableFrom": "project_revisions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_revisions_requested_by_user_id_user_id_fk": { + "name": "project_revisions_requested_by_user_id_user_id_fk", + "tableFrom": "project_revisions", + "tableTo": "user", + "columnsFrom": [ + "requested_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_revisions_status_check": { + "name": "project_revisions_status_check", + "value": "\"project_revisions\".\"status\" in ('pending', 'in_progress', 'completed', 'rejected')" + } + } + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'client_project'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'planning'" + }, + "start_date": { + "name": "start_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "budget_amount_minor": { + "name": "budget_amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "progress_type": { + "name": "progress_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "revision_quota": { + "name": "revision_quota", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "legacy_cover_image_path": { + "name": "legacy_cover_image_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_image_alt": { + "name": "cover_image_alt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "projects_owner_user_id_idx": { + "name": "projects_owner_user_id_idx", + "columns": [ + "owner_user_id" + ], + "isUnique": false + }, + "projects_owner_status_idx": { + "name": "projects_owner_status_idx", + "columns": [ + "owner_user_id", + "status" + ], + "isUnique": false + }, + "projects_client_id_idx": { + "name": "projects_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "projects_due_date_idx": { + "name": "projects_due_date_idx", + "columns": [ + "due_date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "projects_owner_user_id_user_id_fk": { + "name": "projects_owner_user_id_user_id_fk", + "tableFrom": "projects", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_client_id_clients_id_fk": { + "name": "projects_client_id_clients_id_fk", + "tableFrom": "projects", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "projects_type_check": { + "name": "projects_type_check", + "value": "\"projects\".\"type\" in ('client_project', 'side_project')" + }, + "projects_status_check": { + "name": "projects_status_check", + "value": "\"projects\".\"status\" in ('planning', 'active', 'paused', 'completed', 'cancelled')" + }, + "projects_progress_check": { + "name": "projects_progress_check", + "value": "\"projects\".\"progress\" between 0 and 100" + }, + "projects_revision_quota_check": { + "name": "projects_revision_quota_check", + "value": "\"projects\".\"revision_quota\" >= 0" + }, + "projects_budget_check": { + "name": "projects_budget_check", + "value": "\"projects\".\"budget_amount_minor\" is null or \"projects\".\"budget_amount_minor\" >= 0" + }, + "projects_currency_check": { + "name": "projects_currency_check", + "value": "length(\"projects\".\"currency\") = 3" + } + } + }, + "proposals": { + "name": "proposals", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount_minor": { + "name": "amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'TRY'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "valid_until": { + "name": "valid_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "proposals_owner_status_idx": { + "name": "proposals_owner_status_idx", + "columns": [ + "owner_user_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "proposals_owner_user_id_user_id_fk": { + "name": "proposals_owner_user_id_user_id_fk", + "tableFrom": "proposals", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proposals_client_id_clients_id_fk": { + "name": "proposals_client_id_clients_id_fk", + "tableFrom": "proposals", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "proposals_project_id_projects_id_fk": { + "name": "proposals_project_id_projects_id_fk", + "tableFrom": "proposals", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "proposals_status_check": { + "name": "proposals_status_check", + "value": "\"proposals\".\"status\" in ('draft', 'sent', 'accepted', 'rejected')" + }, + "proposals_amount_check": { + "name": "proposals_amount_check", + "value": "\"proposals\".\"amount_minor\" >= 0" + } + } + }, + "subscriptions": { + "name": "subscriptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_minor": { + "name": "amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'TRY'" + }, + "billing_cycle": { + "name": "billing_cycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'monthly'" + }, + "next_billing_date": { + "name": "next_billing_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "subscriptions_owner_status_idx": { + "name": "subscriptions_owner_status_idx", + "columns": [ + "owner_user_id", + "status" + ], + "isUnique": false + }, + "subscriptions_next_billing_date_idx": { + "name": "subscriptions_next_billing_date_idx", + "columns": [ + "next_billing_date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "subscriptions_owner_user_id_user_id_fk": { + "name": "subscriptions_owner_user_id_user_id_fk", + "tableFrom": "subscriptions", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "subscriptions_cycle_check": { + "name": "subscriptions_cycle_check", + "value": "\"subscriptions\".\"billing_cycle\" in ('weekly', 'monthly', 'yearly')" + }, + "subscriptions_status_check": { + "name": "subscriptions_status_check", + "value": "\"subscriptions\".\"status\" in ('active', 'cancelled')" + }, + "subscriptions_amount_check": { + "name": "subscriptions_amount_check", + "value": "\"subscriptions\".\"amount_minor\" >= 0" + } + } + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_journal_entry_id": { + "name": "source_journal_entry_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'todo'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'medium'" + }, + "scheduled_date": { + "name": "scheduled_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_at": { + "name": "due_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimated_minutes": { + "name": "estimated_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actual_minutes": { + "name": "actual_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ai_generated": { + "name": "ai_generated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_public_to_client": { + "name": "is_public_to_client", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "tasks_owner_user_id_idx": { + "name": "tasks_owner_user_id_idx", + "columns": [ + "owner_user_id" + ], + "isUnique": false + }, + "tasks_owner_status_idx": { + "name": "tasks_owner_status_idx", + "columns": [ + "owner_user_id", + "status" + ], + "isUnique": false + }, + "tasks_project_id_idx": { + "name": "tasks_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "tasks_client_id_idx": { + "name": "tasks_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "tasks_due_at_idx": { + "name": "tasks_due_at_idx", + "columns": [ + "due_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_owner_user_id_user_id_fk": { + "name": "tasks_owner_user_id_user_id_fk", + "tableFrom": "tasks", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_client_id_clients_id_fk": { + "name": "tasks_client_id_clients_id_fk", + "tableFrom": "tasks", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_source_journal_entry_id_journal_entries_id_fk": { + "name": "tasks_source_journal_entry_id_journal_entries_id_fk", + "tableFrom": "tasks", + "tableTo": "journal_entries", + "columnsFrom": [ + "source_journal_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "tasks_status_check": { + "name": "tasks_status_check", + "value": "\"tasks\".\"status\" in ('todo', 'in_progress', 'done', 'cancelled')" + }, + "tasks_priority_check": { + "name": "tasks_priority_check", + "value": "\"tasks\".\"priority\" in ('low', 'medium', 'high', 'urgent')" + }, + "tasks_estimated_minutes_check": { + "name": "tasks_estimated_minutes_check", + "value": "\"tasks\".\"estimated_minutes\" is null or \"tasks\".\"estimated_minutes\" >= 0" + }, + "tasks_actual_minutes_check": { + "name": "tasks_actual_minutes_check", + "value": "\"tasks\".\"actual_minutes\" is null or \"tasks\".\"actual_minutes\" >= 0" + } + } + }, + "runtime_checks": { + "name": "runtime_checks", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "runtime_events": { + "name": "runtime_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/server/db/migrations/meta/_journal.json b/server/db/migrations/meta/_journal.json index 11e996a..1a19ae2 100644 --- a/server/db/migrations/meta/_journal.json +++ b/server/db/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1784205329112, "tag": "0002_mighty_korg", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1784208712933, + "tag": "0003_chief_excalibur", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/db/schema/auth.ts b/server/db/schema/auth.ts index d6dd376..847e807 100644 --- a/server/db/schema/auth.ts +++ b/server/db/schema/auth.ts @@ -5,7 +5,7 @@ import type { PortalInvitationStatus, SetupStatus, UserRole, -} from "@/server/auth/types"; +} from "../../auth/types"; const nowMs = sql`(cast(unixepoch('subsecond') * 1000 as integer))`; diff --git a/server/db/schema/domain.ts b/server/db/schema/domain.ts new file mode 100644 index 0000000..1edc6b7 --- /dev/null +++ b/server/db/schema/domain.ts @@ -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().default("active").notNull(), + pipelineStage: text("pipeline_stage").$type().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().default("client_project").notNull(), + description: text("description"), + status: text("status").$type().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().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 | 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().default("todo").notNull(), + priority: text("priority").$type().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().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().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().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().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().notNull(), + title: text("title").notNull(), + content: text("content"), + metadata: text("metadata", { mode: "json" }).$type>().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().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().notNull(), + content: text("content").notNull(), + contextJournalEntryIds: text("context_journal_entry_ids", { mode: "json" }) + .$type() + .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().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().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().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().default("monthly").notNull(), + nextBillingDate: text("next_billing_date"), + status: text("status").$type().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`), + ], +); diff --git a/server/db/schema/index.ts b/server/db/schema/index.ts index e5241f1..a2043d2 100644 --- a/server/db/schema/index.ts +++ b/server/db/schema/index.ts @@ -1,2 +1,3 @@ export * from "./auth"; +export * from "./domain"; export * from "./runtime"; diff --git a/server/domain/actor.ts b/server/domain/actor.ts new file mode 100644 index 0000000..374606b --- /dev/null +++ b/server/domain/actor.ts @@ -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ışı."); + } +} diff --git a/server/domain/database.ts b/server/domain/database.ts new file mode 100644 index 0000000..ae6c29d --- /dev/null +++ b/server/domain/database.ts @@ -0,0 +1,4 @@ +import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; +import * as schema from "../db/schema"; + +export type DomainDatabase = BetterSQLite3Database; diff --git a/server/domain/errors.ts b/server/domain/errors.ts new file mode 100644 index 0000000..128c5b6 --- /dev/null +++ b/server/domain/errors.ts @@ -0,0 +1,38 @@ +export type DomainErrorCode = + | "VALIDATION_ERROR" + | "UNAUTHENTICATED" + | "FORBIDDEN" + | "NOT_FOUND" + | "CONFLICT" + | "INVARIANT_VIOLATION"; + +const statusByCode: Record = { + 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, + ) { + 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); +} diff --git a/server/domain/id.ts b/server/domain/id.ts new file mode 100644 index 0000000..1424253 --- /dev/null +++ b/server/domain/id.ts @@ -0,0 +1,5 @@ +import { randomUUID } from "node:crypto"; + +export type IdGenerator = () => string; + +export const generateId: IdGenerator = randomUUID; diff --git a/server/domain/types.ts b/server/domain/types.ts new file mode 100644 index 0000000..780be0f --- /dev/null +++ b/server/domain/types.ts @@ -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]; diff --git a/server/domain/validation.ts b/server/domain/validation.ts new file mode 100644 index 0000000..13c8d0f --- /dev/null +++ b/server/domain/validation.ts @@ -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( + schema: TSchema, + input: unknown, +): z.output { + 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; +} diff --git a/server/repositories/domain.ts b/server/repositories/domain.ts new file mode 100644 index 0000000..04bc947 --- /dev/null +++ b/server/repositories/domain.ts @@ -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) => + db.insert(clients).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(), + update: (scope: OwnerScope, id: string, value: Partial) => + 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) => + 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) => + db.insert(projects).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(), + update: (scope: OwnerScope, id: string, value: Partial) => + 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) => + db.insert(tasks).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(), + update: (scope: OwnerScope, id: string, value: Partial) => + 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`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) => + 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) => 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) => db.insert(calendarEvents).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(), + update: (scope: OwnerScope, id: string, value: Partial) => 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) => db.insert(financeTransactions).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(), + update: (scope: OwnerScope, id: string, value: Partial) => 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) => db.insert(journalEntries).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(), + updateByDate: (scope: OwnerScope, entryDate: string, value: Partial) => 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) => 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) => db.insert(proposals).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(), + createContract: (scope: OwnerScope, value: Omit) => db.insert(contracts).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(), + createInvoice: (scope: OwnerScope, value: Omit) => db.insert(invoices).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(), + createSubscription: (scope: OwnerScope, value: Omit) => db.insert(subscriptions).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(), + }, + analytics: { + summary: (scope: OwnerScope) => db.select({ + incomeMinor: sql`coalesce(sum(case when ${financeTransactions.type} = 'income' and ${financeTransactions.paymentStatus} = 'paid' then ${financeTransactions.amountMinor} else 0 end), 0)`, + expenseMinor: sql`coalesce(sum(case when ${financeTransactions.type} = 'expense' and ${financeTransactions.paymentStatus} = 'paid' then ${financeTransactions.amountMinor} else 0 end), 0)`, + plannedMinor: sql`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; diff --git a/server/services/domain.ts b/server/services/domain.ts new file mode 100644 index 0000000..7bab37f --- /dev/null +++ b/server/services/domain.ts @@ -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); + } +} diff --git a/server/services/runtime.ts b/server/services/runtime.ts new file mode 100644 index 0000000..36c7bd8 --- /dev/null +++ b/server/services/runtime.ts @@ -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); +} diff --git a/tsconfig.phase2-smoke.json b/tsconfig.phase2-smoke.json new file mode 100644 index 0000000..bee1ad0 --- /dev/null +++ b/tsconfig.phase2-smoke.json @@ -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"] +}