feat(domain): complete phase 2 backend core
This commit is contained in:
@@ -31,7 +31,14 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
function invitationErrorResponse(error: unknown) {
|
function invitationErrorResponse(error: unknown) {
|
||||||
if (error instanceof PortalInvitationError) {
|
if (error instanceof PortalInvitationError) {
|
||||||
const status = error.code === "FORBIDDEN" ? 403 : error.code === "INVALID_INPUT" ? 400 : 409;
|
const status =
|
||||||
|
error.code === "FORBIDDEN"
|
||||||
|
? 403
|
||||||
|
: error.code === "INVALID_INPUT"
|
||||||
|
? 400
|
||||||
|
: error.code === "CLIENT_NOT_FOUND"
|
||||||
|
? 404
|
||||||
|
: 409;
|
||||||
return NextResponse.json({ error: error.message, code: error.code }, { status });
|
return NextResponse.json({ error: error.message, code: error.code }, { status });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ Checklist:
|
|||||||
- [x] Davet iptal edilebiliyor.
|
- [x] Davet iptal edilebiliyor.
|
||||||
- [x] Aynı müşteri için aktif davet politikası tanımlandı.
|
- [x] Aynı müşteri için aktif davet politikası tanımlandı.
|
||||||
- [x] Kabul işlemi transaction içinde.
|
- [x] Kabul işlemi transaction içinde.
|
||||||
- [x] Client profile ve client kimlik bağı atomik kuruluyor (`app_profiles.client_id`; domain FK Faz 2'de eklenecek).
|
- [x] Client profile ve client kimlik bağı atomik kuruluyor (`app_profiles.client_id` + `clients.auth_user_id`).
|
||||||
- [x] Kullanılmış veya süresi dolmuş token tekrar kullanılamıyor.
|
- [x] Kullanılmış veya süresi dolmuş token tekrar kullanılamıyor.
|
||||||
- [x] Client hesabı disable/revoke edilebiliyor.
|
- [x] Client hesabı disable/revoke edilebiliyor.
|
||||||
|
|
||||||
@@ -567,14 +567,14 @@ Transaction içinde doğrulanacaklar:
|
|||||||
|
|
||||||
Checklist:
|
Checklist:
|
||||||
|
|
||||||
- [ ] `project_id` ve `client_id` eşleşmesi server-side doğrulanıyor.
|
- [x] `project_id` ve `client_id` eşleşmesi server-side doğrulanıyor.
|
||||||
- [ ] İstemciden gelen `clientId` güven kaynağı olarak kullanılmıyor.
|
- [x] İstemciden gelen `clientId` güven kaynağı olarak kullanılmıyor.
|
||||||
- [ ] Quota server-side kontrol ediliyor.
|
- [x] Quota server-side kontrol ediliyor.
|
||||||
- [ ] Quota atomik azaltılıyor veya tüketim kayıtlarından hesaplanıyor.
|
- [x] Quota atomik azaltılıyor veya tüketim kayıtlarından hesaplanıyor.
|
||||||
- [ ] Başarısız insert quota tüketmiyor.
|
- [x] Başarısız insert quota tüketmiyor.
|
||||||
- [ ] Cross-project revision negatif testi var.
|
- [x] Cross-project revision negatif testi var.
|
||||||
- [ ] Başka client adına revision oluşturma negatif testi var.
|
- [x] Başka client adına revision oluşturma negatif testi var.
|
||||||
- [ ] Kota aşımı negatif testi var.
|
- [x] Kota aşımı negatif testi var.
|
||||||
|
|
||||||
## 16. API v1 ve mobil hazırlığı
|
## 16. API v1 ve mobil hazırlığı
|
||||||
|
|
||||||
@@ -600,13 +600,13 @@ DELETE /api/v1/device-sessions/:id
|
|||||||
|
|
||||||
Mobil hazırlık checklist'i:
|
Mobil hazırlık checklist'i:
|
||||||
|
|
||||||
- [ ] API response envelope standardı tanımlandı.
|
- [x] API response envelope standardı tanımlandı.
|
||||||
- [ ] API hata kodları tanımlandı.
|
- [x] API hata kodları tanımlandı.
|
||||||
- [ ] API sürümleme stratejisi tanımlandı.
|
- [ ] API sürümleme stratejisi tanımlandı.
|
||||||
- [ ] Instance metadata sözleşmesi tanımlandı.
|
- [ ] Instance metadata sözleşmesi tanımlandı.
|
||||||
- [ ] Minimum desteklenen client sürümü alanı düşünüldü.
|
- [ ] Minimum desteklenen client sürümü alanı düşünüldü.
|
||||||
- [ ] Capability listesi sözleşmesi düşünüldü.
|
- [ ] Capability listesi sözleşmesi düşünüldü.
|
||||||
- [ ] Service katmanı cookie/Next.js objelerine bağımlı değil.
|
- [x] Service katmanı cookie/Next.js objelerine bağımlı değil.
|
||||||
- [ ] Mobil pairing ilk release kapsamı dışında tutuldu.
|
- [ ] Mobil pairing ilk release kapsamı dışında tutuldu.
|
||||||
- [ ] Gelecekte HTTPS zorunluluğu belgelendi.
|
- [ ] Gelecekte HTTPS zorunluluğu belgelendi.
|
||||||
|
|
||||||
@@ -714,9 +714,9 @@ Mümkün olduğunda küçük ve doğrudan test araçları tercih edilir; test al
|
|||||||
- [x] Client invitation testi
|
- [x] Client invitation testi
|
||||||
- [x] Expired/revoked invitation negatif testi
|
- [x] Expired/revoked invitation negatif testi
|
||||||
- [ ] Her repository için cross-owner negatif test
|
- [ ] Her repository için cross-owner negatif test
|
||||||
- [ ] Client private task erişim negatif testi
|
- [x] Client private task erişim negatif testi
|
||||||
- [ ] Revision project-client eşleşme negatif testi
|
- [x] Revision project-client eşleşme negatif testi
|
||||||
- [ ] Revision quota testi
|
- [x] Revision quota testi
|
||||||
- [ ] File upload MIME/size testi
|
- [ ] File upload MIME/size testi
|
||||||
- [ ] Path traversal negatif testi
|
- [ ] Path traversal negatif testi
|
||||||
- [x] Backup oluşturma testi
|
- [x] Backup oluşturma testi
|
||||||
@@ -820,13 +820,22 @@ Faz 1 tamamlanma notu (2026-07-16):
|
|||||||
|
|
||||||
Amaç: Tüm çekirdek iş verileri için Drizzle schema, migration, repository ve service katmanını kurmak.
|
Amaç: Tüm çekirdek iş verileri için Drizzle schema, migration, repository ve service katmanını kurmak.
|
||||||
|
|
||||||
- [ ] Çekirdek tablolar oluşturuldu.
|
- [x] Çekirdek tablolar oluşturuldu.
|
||||||
- [ ] Repository katmanı oluşturuldu.
|
- [x] Repository katmanı oluşturuldu.
|
||||||
- [ ] Service katmanı oluşturuldu.
|
- [x] Service katmanı oluşturuldu.
|
||||||
- [ ] Actor/authorization sözleşmesi standartlaştırıldı.
|
- [x] Actor/authorization sözleşmesi standartlaştırıldı.
|
||||||
- [ ] Validation ve error sözleşmesi standartlaştırıldı.
|
- [x] Validation ve error sözleşmesi standartlaştırıldı.
|
||||||
- [ ] Negatif authorization testleri yazıldı.
|
- [x] Negatif authorization testleri yazıldı.
|
||||||
- [ ] Analytics aggregate sorgu yaklaşımı belirlendi.
|
- [x] Analytics aggregate sorgu yaklaşımı belirlendi.
|
||||||
|
|
||||||
|
Faz 2 tamamlanma notu (2026-07-16):
|
||||||
|
|
||||||
|
- 11 çekirdek domain tablosu ile kaynak verisi korunacak 4 business tablosu Drizzle schema ve migration'a eklendi; storage ve branding tabloları Faz 3 sınırında bırakıldı.
|
||||||
|
- Scope zorunlu repository katmanı ve Next.js/session bağımsız service katmanı; CRUD, ilişki tutarlılığı, otomatik proje ilerlemesi, portal görünürlüğü ve aggregate sorguları uygular.
|
||||||
|
- Davet hedefi yerel owner-scoped client kaydına bağlandı. Kabul transaction'ı `app_profiles.client_id` ve `clients.auth_user_id` kimlik bağlarını birlikte kurar; client session bu iki yönlü bağı doğrular.
|
||||||
|
- Revizyon isteği `BEGIN IMMEDIATE` transaction içinde actor-derived client, proje ilişkisi, aktif proje ve tüketim kayıtlarından kota kontrolüyle oluşturulur.
|
||||||
|
- `phase2:domain-smoke`; cross-owner, client owner-only erişimi, private task, başka client/proje, kota aşımı, ilişkisel owner ve SQLite constraint negatiflerini gerçek migration uygulanmış veritabanında doğrular.
|
||||||
|
- Tasarım ve doğrulama ayrıntıları `phase-2-domain-core.md` belgesinde kaydedildi.
|
||||||
|
|
||||||
Çıkış kriteri: Çekirdek domain işlemleri UI veya Supabase'e bağımlı olmadan test edilebiliyor.
|
Çıkış kriteri: Çekirdek domain işlemleri UI veya Supabase'e bağımlı olmadan test edilebiliyor.
|
||||||
|
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ Bu dosya tarihsel adı korunarak Faz 1'de tamamlanan Better Auth + SQLite auth t
|
|||||||
- Token `randomBytes(32)` ile üretilir; SQLite'ta yalnızca SHA-256 hash saklanır.
|
- Token `randomBytes(32)` ile üretilir; SQLite'ta yalnızca SHA-256 hash saklanır.
|
||||||
- Varsayılan TTL 72 saat, servis üst sınırı 168 saattir.
|
- Varsayılan TTL 72 saat, servis üst sınırı 168 saattir.
|
||||||
- Aynı `clientId` için yeni davet önceki pending davetleri revoke eder ve bu değişiklik audit edilir.
|
- Aynı `clientId` için yeni davet önceki pending davetleri revoke eder ve bu değişiklik audit edilir.
|
||||||
- Kabul sırasında Better Auth `user`, credential `account`, `app_profiles` client kaydı, `client_id` identity bağı ve invitation `accepted` durumu tek SQLite transaction'ında yazılır.
|
- Kabul sırasında Better Auth `user`, credential `account`, `app_profiles` client kaydı, `clients.auth_user_id` bağı ve invitation `accepted` durumu tek SQLite transaction'ında yazılır.
|
||||||
- Kullanılmış, değiştirilmiş, süresi dolmuş veya revoke edilmiş token yeniden kullanılamaz.
|
- Kullanılmış, değiştirilmiş, süresi dolmuş veya revoke edilmiş token yeniden kullanılamaz.
|
||||||
- Disable işlemi profile'ı kapatır ve o client'ın aktif Better Auth session kayıtlarını aynı transaction'da siler.
|
- Disable işlemi profile'ı kapatır ve o client'ın aktif Better Auth session kayıtlarını aynı transaction'da siler.
|
||||||
- Disabled veya `client_id` bağı eksik client, Better Auth endpoint'ini doğrudan çağırsa bile session oluşturamaz.
|
- Disabled veya `client_id` bağı eksik client, Better Auth endpoint'ini doğrudan çağırsa bile session oluşturamaz.
|
||||||
|
|
||||||
`app_profiles.client_id`, Faz 1 auth sınırında opaque domain identity bağıdır. Yerel `clients` tablosu ve foreign key Faz 2 domain migration'ında eklenecektir; mevcut Supabase client sayfaları bu nedenle henüz domain açısından hibrittir.
|
Faz 2 ile yerel `clients` tablosu eklenmiştir. Davet hedefi artık owner'a ait gerçek bir client kaydı olmak zorundadır; kabul transaction'ı `app_profiles.client_id` ile `clients.auth_user_id` bağlarını birlikte kurar ve session çözümlemesi iki yönlü bağın eşleştiğini doğrular. Mevcut ekran sorgularının Supabase'ten service katmanına taşınması sayfa bazlı dönüşüm fazlarında sürdürülecektir.
|
||||||
|
|
||||||
## Audit kapsamı
|
## Audit kapsamı
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
title: Faz 2 Domain Schema ve Backend Çekirdeği
|
||||||
|
description: SQLite/Drizzle domain modeli, repository-service sınırı, actor yetkilendirmesi ve test sözleşmesi.
|
||||||
|
status: complete
|
||||||
|
last_updated: 2026-07-16
|
||||||
|
---
|
||||||
|
|
||||||
|
# Faz 2 Domain Schema ve Backend Çekirdeği
|
||||||
|
|
||||||
|
Faz 2, Neta'nın iş verilerini Supabase istemcisinden ayıran çalıştırılabilir backend çekirdeğini kurar. Bu faz sayfa sorgularını henüz taşımaz; Drizzle schema, migration, repository ve service katmanları UI, cookie ve Next.js request objelerinden bağımsızdır. Route Handler veya Server Action yalnızca session'ı `DomainActor`'a çevirip service çağırmalıdır.
|
||||||
|
|
||||||
|
## Veri modeli
|
||||||
|
|
||||||
|
`0003_chief_excalibur.sql` migration'ı 15 domain tablosunu ekler:
|
||||||
|
|
||||||
|
- çekirdek: `clients`, `client_activities`, `projects`, `project_planning_sections`, `tasks`, `calendar_events`, `finance_transactions`, `journal_entries`, `project_revisions`, `chat_sessions`, `chat_messages`;
|
||||||
|
- korunacak iş verileri: `proposals`, `contracts`, `invoices`, `subscriptions`.
|
||||||
|
|
||||||
|
Dosya metadata'sı ve instance branding bilinçli olarak Faz 3'e bırakılmıştır. ID'ler mevcut UUID'leri taşıyabilmek için `text`, parasal değerler integer minor unit, iş tarihleri `YYYY-MM-DD`, sistem zamanları UTC epoch millisecond olarak saklanır. Owner'a ait tablolarda açık `owner_user_id`, client portal bağında `clients.auth_user_id` bulunur.
|
||||||
|
|
||||||
|
Schema seviyesinde status/type enumları, negatif para ve süre değerleri, progress aralığı, revizyon kotası, tarih aralığı, currency uzunluğu, journal owner+date tekilliği ve invoice owner+number tekilliği SQLite `CHECK`/unique constraint'leriyle korunur. İlişkisel owner tutarlılığı service katmanında doğrulanır; istemciden gelen owner veya client kimliği güven kaynağı değildir.
|
||||||
|
|
||||||
|
## Katman sınırları
|
||||||
|
|
||||||
|
| Katman | Sorumluluk | Bağımlı olmadığı şeyler |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Schema | tablo, foreign key, index ve DB constraint | UI, Supabase |
|
||||||
|
| Repository | scope uygulanmış Drizzle sorguları ve aggregate'ler | session/cookie, Next.js |
|
||||||
|
| Service | validation, actor yetkisi, ilişki ve iş kuralları | Route Handler, React |
|
||||||
|
| Adapter | session→actor ve HTTP response dönüşümü | domain kuralı |
|
||||||
|
|
||||||
|
Ana giriş noktaları:
|
||||||
|
|
||||||
|
- `server/domain/actor.ts`: `DomainActor`, `OwnerScope`, `ClientScope` ve role/disabled guard'ları;
|
||||||
|
- `server/domain/validation.ts`: paylaşılan Zod input sözleşmeleri;
|
||||||
|
- `server/domain/errors.ts`: stabil domain error code ve HTTP status eşlemesi;
|
||||||
|
- `server/repositories/domain.ts`: owner/client scope'u sorgu koşuluna dönüştüren repository'ler;
|
||||||
|
- `server/services/domain.ts`: CRUD, ilişki doğrulaması, portal görünürlüğü, revizyon ve aggregate kuralları;
|
||||||
|
- `server/api/responses.ts`: `{ ok, data }` ve `{ ok, error }` API envelope'u;
|
||||||
|
- `server/auth/domain-actor.ts`: web session adapter'ı.
|
||||||
|
|
||||||
|
Repository metoduna çıplak `ownerUserId` yerine tiplenmiş scope verilir. Owner kaynaklarında kimlik filtresi her sorguda uygulanır. Client proje erişimi bağlı `clientId`, görev erişimi ayrıca `is_public_to_client = true` üzerinden kısıtlanır. Calendar, finance, journal ve chat client rolüne kapalıdır.
|
||||||
|
|
||||||
|
## İş kuralları
|
||||||
|
|
||||||
|
- Side project bir client'a bağlanamaz.
|
||||||
|
- Client/project/task/journal ilişkileri aynı owner altında bulunmalı ve birbiriyle uyuşmalıdır.
|
||||||
|
- Otomatik progress kullanan projeler, iptal edilmemiş görevlerdeki `done / total` oranından create/update/delete sonrasında yeniden hesaplanır.
|
||||||
|
- Journal aynı owner ve iş tarihi için upsert edilir.
|
||||||
|
- Chat session ve journal context kayıtları aynı owner'a ait olmak zorundadır.
|
||||||
|
- Business preservation tablolarına yazılan client/project/proposal bağları owner scope'unda doğrulanır.
|
||||||
|
- Davet yalnızca owner'a ait gerçek bir `clients` kaydı için üretilebilir. Kabul işlemi auth kayıtlarıyla birlikte `clients.auth_user_id` değerini aynı transaction'da yazar; session çözümlemesi `app_profiles.client_id` ile bu bağı karşılıklı doğrular.
|
||||||
|
|
||||||
|
## Revizyon transaction'ı
|
||||||
|
|
||||||
|
Revizyon isteği client actor'dan `clientId` almaz; client kimliği actor scope'undan gelir. `BEGIN IMMEDIATE` transaction içinde proje-client eşleşmesi, projenin aktif olması ve reddedilmemiş tüketim kayıtlarından kalan kota kontrol edilir, sonra insert yapılır. Bu yaklaşım ayrı bir mutable sayaç tutmaz; başarısız transaction kota tüketmez ve eşzamanlı yazarlar kontrol ile insert arasına giremez.
|
||||||
|
|
||||||
|
## Analytics yaklaşımı
|
||||||
|
|
||||||
|
Analytics için satırların tamamını belleğe alıp JavaScript'te toplamak yerine repository seviyesinde doğrudan SQLite aggregate sorguları kullanılır:
|
||||||
|
|
||||||
|
- ödenmiş gelir, ödenmiş gider ve planlanan/pending tutarlar koşullu `SUM` ile;
|
||||||
|
- proje ve görev durum dağılımları `GROUP BY` + `COUNT` ile;
|
||||||
|
- tüm sorgular `owner_user_id` scope'u ile.
|
||||||
|
|
||||||
|
İleride dashboard zaman serileri de aynı yaklaşımda tarih aralığı ve currency filtresi eklenerek genişletilmelidir. Farklı para birimleri kur bilgisi olmadan birbirine çevrilmemelidir.
|
||||||
|
|
||||||
|
## Doğrulama
|
||||||
|
|
||||||
|
`npm run phase2:domain-smoke` her çalışmada boş bir SQLite dosyasına gerçek migration'ları uygular, saf TypeScript domain çekirdeğini derler ve aşağıdaki senaryoları doğrular:
|
||||||
|
|
||||||
|
- owner CRUD scope'u ve cross-owner kaynak reddi;
|
||||||
|
- client'ın owner-only modüllerden reddi;
|
||||||
|
- bağlı proje/planlama görünürlüğü ve private task sızıntısının engellenmesi;
|
||||||
|
- otomatik project progress;
|
||||||
|
- project-client eşleşmesi, aktif proje kuralı, atomik quota ve quota aşımı;
|
||||||
|
- journal upsert, chat ownership ve ilişkisel owner doğrulamaları;
|
||||||
|
- owner-scope finance aggregate sonuçları;
|
||||||
|
- korunacak dört business tablosuna service üzerinden yazım;
|
||||||
|
- negatif amount ve geçersiz status için SQLite CHECK constraint'leri.
|
||||||
|
|
||||||
|
Faz 1 auth smoke'u da yerel client fixture'larıyla çalışır ve davet kabulünden sonra hem profile hem `clients.auth_user_id` bağını doğrular.
|
||||||
|
|
||||||
|
| Kontrol | Sonuç |
|
||||||
|
| --- | --- |
|
||||||
|
| `npm run typecheck` | Başarılı |
|
||||||
|
| Değişen Faz 2 dosyalarında targeted ESLint | 0 error, 0 warning |
|
||||||
|
| `npm run phase2:domain-smoke` | Başarılı |
|
||||||
|
| `node scripts/phase1-auth-smoke.mjs` | Başarılı |
|
||||||
|
| `node scripts/phase1-smoke.mjs` | Başarılı; migration, backup ve restore dahil |
|
||||||
|
| `pnpm db:generate` | Schema drift yok |
|
||||||
|
| `npm run build` | Başarılı |
|
||||||
|
|
||||||
|
Repo geneli lint, Faz 0'dan kaydedilmiş ve bu fazın değiştirmediği UI/AI dosyalarındaki baseline nedeniyle 31 error ve 18 warning ile açık kalır. Faz 2 dosyaları bu bulgulara yenisini eklemez.
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"db:restore": "node scripts/restore.mjs",
|
"db:restore": "node scripts/restore.mjs",
|
||||||
"phase1:smoke": "node scripts/phase1-smoke.mjs",
|
"phase1:smoke": "node scripts/phase1-smoke.mjs",
|
||||||
"phase1:auth-smoke": "node scripts/phase1-auth-smoke.mjs",
|
"phase1:auth-smoke": "node scripts/phase1-auth-smoke.mjs",
|
||||||
|
"phase2:domain-smoke": "node scripts/phase2-domain-smoke.mjs",
|
||||||
"phase2:smoke": "node scripts/phase2-auth-smoke.mjs",
|
"phase2:smoke": "node scripts/phase2-auth-smoke.mjs",
|
||||||
"phase3:ui-boundary": "node scripts/phase3-ui-boundary.mjs"
|
"phase3:ui-boundary": "node scripts/phase3-ui-boundary.mjs"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -78,6 +78,20 @@ try {
|
|||||||
"Exactly one freelancer profile must exist",
|
"Exactly one freelancer profile must exist",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const ownerUserId = db
|
||||||
|
.prepare("select auth_user_id as authUserId from app_profiles where role = 'freelancer'")
|
||||||
|
.get().authUserId;
|
||||||
|
const insertClient = db.prepare(
|
||||||
|
"insert into clients (id, owner_user_id, name) values (?, ?, ?)",
|
||||||
|
);
|
||||||
|
for (const [clientId, name] of [
|
||||||
|
["client-alpha", "Alpha Client"],
|
||||||
|
["client-expired", "Expired Client"],
|
||||||
|
["client-revoked", "Revoked Client"],
|
||||||
|
]) {
|
||||||
|
insertClient.run(clientId, ownerUserId, name);
|
||||||
|
}
|
||||||
|
|
||||||
const rejectedRegistration = await authPost("/api/auth/sign-up/email", {
|
const rejectedRegistration = await authPost("/api/auth/sign-up/email", {
|
||||||
name: "Public Attacker",
|
name: "Public Attacker",
|
||||||
email: "attacker@example.com",
|
email: "attacker@example.com",
|
||||||
@@ -110,6 +124,13 @@ try {
|
|||||||
});
|
});
|
||||||
assert.equal(invalidInvite.response.status, 400, "Invalid invitation input must fail");
|
assert.equal(invalidInvite.response.status, 400, "Invalid invitation input must fail");
|
||||||
|
|
||||||
|
const missingClientInvite = await jsonRequest("/api/portal-invitations", {
|
||||||
|
method: "POST",
|
||||||
|
cookie: ownerCookie,
|
||||||
|
body: { clientId: "missing-client", email: "missing@example.com" },
|
||||||
|
});
|
||||||
|
assert.equal(missingClientInvite.response.status, 404, "Invitation target must be an owned client");
|
||||||
|
|
||||||
const firstInvite = await jsonRequest("/api/portal-invitations", {
|
const firstInvite = await jsonRequest("/api/portal-invitations", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
cookie: ownerCookie,
|
cookie: ownerCookie,
|
||||||
@@ -150,6 +171,12 @@ try {
|
|||||||
.get("client@example.com"),
|
.get("client@example.com"),
|
||||||
{ role: "client", clientId: "client-alpha", disabled: 0 },
|
{ role: "client", clientId: "client-alpha", disabled: 0 },
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
db.prepare("select auth_user_id as authUserId from clients where id = ?").get("client-alpha")
|
||||||
|
.authUserId,
|
||||||
|
clientAuthUserId,
|
||||||
|
"Accepted invitation must atomically link the domain client",
|
||||||
|
);
|
||||||
assert.notEqual(
|
assert.notEqual(
|
||||||
db.prepare("select password from account where user_id = ?").get(clientAuthUserId).password,
|
db.prepare("select password from account where user_id = ?").get(clientAuthUserId).password,
|
||||||
"Client-Password-123",
|
"Client-Password-123",
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { DomainError } from "../domain/errors";
|
||||||
|
|
||||||
|
export function apiSuccess<T>(data: T, init?: ResponseInit): NextResponse {
|
||||||
|
return NextResponse.json({ ok: true, data }, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiError(error: unknown): NextResponse {
|
||||||
|
if (error instanceof DomainError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: error.code,
|
||||||
|
message: error.message,
|
||||||
|
...(error.details ? { details: error.details } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: error.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("Unhandled API error", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: { code: "INTERNAL_ERROR", message: "Beklenmeyen bir sunucu hatası oluştu." } },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import type { DomainActor } from "../domain/actor";
|
||||||
|
import type { SessionContext } from "./session";
|
||||||
|
|
||||||
|
export function domainActorFromSession(context: SessionContext): DomainActor {
|
||||||
|
return {
|
||||||
|
authUserId: context.user.id,
|
||||||
|
role: context.profile.role,
|
||||||
|
clientId: context.profile.clientId,
|
||||||
|
disabled: context.profile.disabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import "server-only";
|
|||||||
|
|
||||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||||
import { hashPassword } from "better-auth/crypto";
|
import { hashPassword } from "better-auth/crypto";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { SessionContext } from "@/server/auth/session";
|
import type { SessionContext } from "@/server/auth/session";
|
||||||
import { getServerConfig } from "@/server/config";
|
import { getServerConfig } from "@/server/config";
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
account,
|
account,
|
||||||
appProfiles,
|
appProfiles,
|
||||||
authAuditEvents,
|
authAuditEvents,
|
||||||
|
clients,
|
||||||
portalInvitations,
|
portalInvitations,
|
||||||
session,
|
session,
|
||||||
user,
|
user,
|
||||||
@@ -37,6 +38,7 @@ export type PortalInvitationErrorCode =
|
|||||||
| "INVITATION_NOT_FOUND"
|
| "INVITATION_NOT_FOUND"
|
||||||
| "INVITATION_NOT_PENDING"
|
| "INVITATION_NOT_PENDING"
|
||||||
| "INVITATION_EXPIRED"
|
| "INVITATION_EXPIRED"
|
||||||
|
| "CLIENT_NOT_FOUND"
|
||||||
| "CLIENT_ALREADY_LINKED"
|
| "CLIENT_ALREADY_LINKED"
|
||||||
| "EMAIL_ALREADY_REGISTERED";
|
| "EMAIL_ALREADY_REGISTERED";
|
||||||
|
|
||||||
@@ -70,6 +72,25 @@ export async function createPortalInvitation(
|
|||||||
const { db } = getSqliteConnection();
|
const { db } = getSqliteConnection();
|
||||||
|
|
||||||
const invitationId = db.transaction((tx) => {
|
const invitationId = db.transaction((tx) => {
|
||||||
|
const client = tx
|
||||||
|
.select({ id: clients.id, authUserId: clients.authUserId })
|
||||||
|
.from(clients)
|
||||||
|
.where(
|
||||||
|
and(eq(clients.id, parsed.clientId), eq(clients.ownerUserId, actor.user.id)),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
throw new PortalInvitationError("CLIENT_NOT_FOUND", "Müşteri bulunamadı.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.authUserId) {
|
||||||
|
throw new PortalInvitationError(
|
||||||
|
"CLIENT_ALREADY_LINKED",
|
||||||
|
"Bu müşteri için portal hesabı zaten mevcut.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const [linkedProfile] = tx
|
const [linkedProfile] = tx
|
||||||
.select({ id: appProfiles.id })
|
.select({ id: appProfiles.id })
|
||||||
.from(appProfiles)
|
.from(appProfiles)
|
||||||
@@ -333,6 +354,24 @@ export async function acceptPortalInvitation(input: {
|
|||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
|
const linkedClient = tx
|
||||||
|
.update(clients)
|
||||||
|
.set({ authUserId, updatedAt: now })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clients.id, invitation.clientId),
|
||||||
|
isNull(clients.authUserId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
if (linkedClient.changes !== 1) {
|
||||||
|
throw new PortalInvitationError(
|
||||||
|
"CLIENT_ALREADY_LINKED",
|
||||||
|
"Müşteri kaydı bulunamadı veya başka bir hesaba bağlandı.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const accepted = tx
|
const accepted = tx
|
||||||
.update(portalInvitations)
|
.update(portalInvitations)
|
||||||
.set({ status: "accepted", acceptedAt: now })
|
.set({ status: "accepted", acceptedAt: now })
|
||||||
@@ -417,6 +456,16 @@ export function setClientPortalAccess(
|
|||||||
const { db } = getSqliteConnection();
|
const { db } = getSqliteConnection();
|
||||||
|
|
||||||
db.transaction((tx) => {
|
db.transaction((tx) => {
|
||||||
|
const ownedClient = tx
|
||||||
|
.select({ id: clients.id })
|
||||||
|
.from(clients)
|
||||||
|
.where(and(eq(clients.id, clientId), eq(clients.ownerUserId, actor.user.id)))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!ownedClient) {
|
||||||
|
throw new PortalInvitationError("CLIENT_NOT_FOUND", "Müşteri bulunamadı.");
|
||||||
|
}
|
||||||
|
|
||||||
const [profile] = tx
|
const [profile] = tx
|
||||||
.select({ authUserId: appProfiles.authUserId, email: appProfiles.email })
|
.select({ authUserId: appProfiles.authUserId, email: appProfiles.email })
|
||||||
.from(appProfiles)
|
.from(appProfiles)
|
||||||
|
|||||||
+24
-3
@@ -1,13 +1,13 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import { auth } from "@/server/auth/auth";
|
import { auth } from "@/server/auth/auth";
|
||||||
import type { UserRole } from "@/server/auth/types";
|
import type { UserRole } from "@/server/auth/types";
|
||||||
import { getSqliteConnection } from "@/server/db/client";
|
import { getSqliteConnection } from "@/server/db/client";
|
||||||
import { appProfiles } from "@/server/db/schema";
|
import { appProfiles, clients } from "@/server/db/schema";
|
||||||
|
|
||||||
type BetterAuthSession = NonNullable<Awaited<ReturnType<typeof auth.api.getSession>>>;
|
type BetterAuthSession = NonNullable<Awaited<ReturnType<typeof auth.api.getSession>>>;
|
||||||
|
|
||||||
@@ -108,5 +108,26 @@ export function getProfileByAuthUserId(authUserId: string): SessionContext["prof
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
return profile ?? null;
|
if (!profile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.role === "client") {
|
||||||
|
if (!profile.clientId) return null;
|
||||||
|
|
||||||
|
const linkedClient = db
|
||||||
|
.select({ id: clients.id })
|
||||||
|
.from(clients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clients.id, profile.clientId),
|
||||||
|
eq(clients.authUserId, profile.authUserId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!linkedClient) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
CREATE TABLE `calendar_events` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`owner_user_id` text NOT NULL,
|
||||||
|
`client_id` text,
|
||||||
|
`project_id` text,
|
||||||
|
`task_id` text,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`description` text,
|
||||||
|
`type` text DEFAULT 'focus' NOT NULL,
|
||||||
|
`starts_at` integer NOT NULL,
|
||||||
|
`ends_at` integer,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
FOREIGN KEY (`task_id`) REFERENCES `tasks`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
CONSTRAINT "calendar_events_type_check" CHECK("calendar_events"."type" in ('meeting', 'focus', 'deadline', 'personal', 'finance')),
|
||||||
|
CONSTRAINT "calendar_events_time_check" CHECK("calendar_events"."ends_at" is null or "calendar_events"."ends_at" >= "calendar_events"."starts_at")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `calendar_events_owner_range_idx` ON `calendar_events` (`owner_user_id`,`starts_at`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `calendar_events_project_id_idx` ON `calendar_events` (`project_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `calendar_events_task_id_idx` ON `calendar_events` (`task_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `chat_messages` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`session_id` text NOT NULL,
|
||||||
|
`role` text NOT NULL,
|
||||||
|
`content` text NOT NULL,
|
||||||
|
`context_journal_entry_ids` text DEFAULT '[]' NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
FOREIGN KEY (`session_id`) REFERENCES `chat_sessions`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
CONSTRAINT "chat_messages_role_check" CHECK("chat_messages"."role" in ('system', 'user', 'assistant', 'tool'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `chat_messages_session_created_idx` ON `chat_messages` (`session_id`,`created_at`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `chat_sessions` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`owner_user_id` text NOT NULL,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `chat_sessions_owner_updated_idx` ON `chat_sessions` (`owner_user_id`,`updated_at`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `client_activities` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`owner_user_id` text NOT NULL,
|
||||||
|
`client_id` text NOT NULL,
|
||||||
|
`type` text NOT NULL,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`content` text,
|
||||||
|
`activity_date` integer NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
CONSTRAINT "client_activities_type_check" CHECK("client_activities"."type" in ('note', 'call', 'meeting', 'email'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `client_activities_owner_client_idx` ON `client_activities` (`owner_user_id`,`client_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `client_activities_client_date_idx` ON `client_activities` (`client_id`,`activity_date`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `clients` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`owner_user_id` text NOT NULL,
|
||||||
|
`auth_user_id` text,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`company_name` text,
|
||||||
|
`email` text,
|
||||||
|
`phone` text,
|
||||||
|
`website` text,
|
||||||
|
`status` text DEFAULT 'active' NOT NULL,
|
||||||
|
`pipeline_stage` text DEFAULT 'lead' NOT NULL,
|
||||||
|
`next_follow_up_date` text,
|
||||||
|
`notes` text,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`auth_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
CONSTRAINT "clients_status_check" CHECK("clients"."status" in ('active', 'paused', 'archived')),
|
||||||
|
CONSTRAINT "clients_pipeline_stage_check" CHECK("clients"."pipeline_stage" in ('lead', 'contacted', 'proposal_sent', 'won', 'lost'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `clients_auth_user_id_unique` ON `clients` (`auth_user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `clients_owner_user_id_idx` ON `clients` (`owner_user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `clients_owner_status_idx` ON `clients` (`owner_user_id`,`status`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `clients_owner_pipeline_idx` ON `clients` (`owner_user_id`,`pipeline_stage`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `clients_next_follow_up_date_idx` ON `clients` (`next_follow_up_date`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `contracts` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`owner_user_id` text NOT NULL,
|
||||||
|
`proposal_id` text,
|
||||||
|
`client_id` text,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`content` text,
|
||||||
|
`status` text DEFAULT 'draft' NOT NULL,
|
||||||
|
`signed_at` integer,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`proposal_id`) REFERENCES `proposals`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
CONSTRAINT "contracts_status_check" CHECK("contracts"."status" in ('draft', 'active', 'completed', 'cancelled'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `contracts_owner_status_idx` ON `contracts` (`owner_user_id`,`status`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `finance_transactions` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`owner_user_id` text NOT NULL,
|
||||||
|
`client_id` text,
|
||||||
|
`project_id` text,
|
||||||
|
`type` text NOT NULL,
|
||||||
|
`amount_minor` integer NOT NULL,
|
||||||
|
`currency` text DEFAULT 'USD' NOT NULL,
|
||||||
|
`transaction_date` text NOT NULL,
|
||||||
|
`category` text,
|
||||||
|
`payment_status` text DEFAULT 'planned' NOT NULL,
|
||||||
|
`description` text,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
CONSTRAINT "finance_transactions_type_check" CHECK("finance_transactions"."type" in ('income', 'expense')),
|
||||||
|
CONSTRAINT "finance_transactions_amount_check" CHECK("finance_transactions"."amount_minor" >= 0),
|
||||||
|
CONSTRAINT "finance_transactions_payment_status_check" CHECK("finance_transactions"."payment_status" in ('planned', 'pending', 'paid', 'cancelled')),
|
||||||
|
CONSTRAINT "finance_transactions_currency_check" CHECK(length("finance_transactions"."currency") = 3)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `finance_transactions_owner_date_idx` ON `finance_transactions` (`owner_user_id`,`transaction_date`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `finance_transactions_owner_type_idx` ON `finance_transactions` (`owner_user_id`,`type`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `finance_transactions_client_id_idx` ON `finance_transactions` (`client_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `finance_transactions_project_id_idx` ON `finance_transactions` (`project_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `invoices` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`owner_user_id` text NOT NULL,
|
||||||
|
`client_id` text,
|
||||||
|
`project_id` text,
|
||||||
|
`invoice_number` text NOT NULL,
|
||||||
|
`amount_minor` integer DEFAULT 0 NOT NULL,
|
||||||
|
`tax_basis_points` integer DEFAULT 0 NOT NULL,
|
||||||
|
`currency` text DEFAULT 'TRY' NOT NULL,
|
||||||
|
`status` text DEFAULT 'draft' NOT NULL,
|
||||||
|
`issue_date` text NOT NULL,
|
||||||
|
`due_date` text,
|
||||||
|
`paid_at` integer,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
CONSTRAINT "invoices_status_check" CHECK("invoices"."status" in ('draft', 'sent', 'paid', 'overdue', 'cancelled')),
|
||||||
|
CONSTRAINT "invoices_amount_check" CHECK("invoices"."amount_minor" >= 0),
|
||||||
|
CONSTRAINT "invoices_tax_check" CHECK("invoices"."tax_basis_points" between 0 and 10000)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `invoices_owner_number_unique` ON `invoices` (`owner_user_id`,`invoice_number`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `invoices_owner_status_idx` ON `invoices` (`owner_user_id`,`status`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `journal_entries` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`owner_user_id` text NOT NULL,
|
||||||
|
`entry_date` text NOT NULL,
|
||||||
|
`mood_score` integer,
|
||||||
|
`energy_score` integer,
|
||||||
|
`work_satisfaction_score` integer,
|
||||||
|
`mood_label` text,
|
||||||
|
`note` text,
|
||||||
|
`legacy_ai_metadata` text,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
CONSTRAINT "journal_entries_mood_score_check" CHECK("journal_entries"."mood_score" is null or "journal_entries"."mood_score" between 1 and 5),
|
||||||
|
CONSTRAINT "journal_entries_energy_score_check" CHECK("journal_entries"."energy_score" is null or "journal_entries"."energy_score" between 1 and 5),
|
||||||
|
CONSTRAINT "journal_entries_work_score_check" CHECK("journal_entries"."work_satisfaction_score" is null or "journal_entries"."work_satisfaction_score" between 1 and 5)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `journal_entries_owner_date_unique` ON `journal_entries` (`owner_user_id`,`entry_date`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `journal_entries_owner_date_idx` ON `journal_entries` (`owner_user_id`,`entry_date`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `project_planning_sections` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`owner_user_id` text NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`category` text NOT NULL,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`content` text,
|
||||||
|
`metadata` text DEFAULT '{}' NOT NULL,
|
||||||
|
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
CONSTRAINT "project_planning_sections_category_check" CHECK("project_planning_sections"."category" in ('overview', 'problem', 'goal', 'audience', 'scope', 'design_system', 'color_palette', 'typography', 'assets', 'notes'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `project_planning_sections_owner_idx` ON `project_planning_sections` (`owner_user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `project_planning_sections_project_order_idx` ON `project_planning_sections` (`project_id`,`sort_order`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `project_revisions` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`owner_user_id` text NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`client_id` text NOT NULL,
|
||||||
|
`requested_by_user_id` text NOT NULL,
|
||||||
|
`description` text NOT NULL,
|
||||||
|
`status` text DEFAULT 'pending' NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`requested_by_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
CONSTRAINT "project_revisions_status_check" CHECK("project_revisions"."status" in ('pending', 'in_progress', 'completed', 'rejected'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `project_revisions_owner_project_idx` ON `project_revisions` (`owner_user_id`,`project_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `project_revisions_client_project_idx` ON `project_revisions` (`client_id`,`project_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `projects` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`owner_user_id` text NOT NULL,
|
||||||
|
`client_id` text,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`type` text DEFAULT 'client_project' NOT NULL,
|
||||||
|
`description` text,
|
||||||
|
`status` text DEFAULT 'planning' NOT NULL,
|
||||||
|
`start_date` text,
|
||||||
|
`due_date` text,
|
||||||
|
`budget_amount_minor` integer,
|
||||||
|
`currency` text DEFAULT 'USD' NOT NULL,
|
||||||
|
`progress` integer DEFAULT 0 NOT NULL,
|
||||||
|
`progress_type` text DEFAULT 'manual' NOT NULL,
|
||||||
|
`revision_quota` integer DEFAULT 0 NOT NULL,
|
||||||
|
`legacy_cover_image_path` text,
|
||||||
|
`cover_image_alt` text,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
CONSTRAINT "projects_type_check" CHECK("projects"."type" in ('client_project', 'side_project')),
|
||||||
|
CONSTRAINT "projects_status_check" CHECK("projects"."status" in ('planning', 'active', 'paused', 'completed', 'cancelled')),
|
||||||
|
CONSTRAINT "projects_progress_check" CHECK("projects"."progress" between 0 and 100),
|
||||||
|
CONSTRAINT "projects_revision_quota_check" CHECK("projects"."revision_quota" >= 0),
|
||||||
|
CONSTRAINT "projects_budget_check" CHECK("projects"."budget_amount_minor" is null or "projects"."budget_amount_minor" >= 0),
|
||||||
|
CONSTRAINT "projects_currency_check" CHECK(length("projects"."currency") = 3)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `projects_owner_user_id_idx` ON `projects` (`owner_user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `projects_owner_status_idx` ON `projects` (`owner_user_id`,`status`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `projects_client_id_idx` ON `projects` (`client_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `projects_due_date_idx` ON `projects` (`due_date`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `proposals` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`owner_user_id` text NOT NULL,
|
||||||
|
`client_id` text,
|
||||||
|
`project_id` text,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`description` text,
|
||||||
|
`amount_minor` integer DEFAULT 0 NOT NULL,
|
||||||
|
`currency` text DEFAULT 'TRY' NOT NULL,
|
||||||
|
`status` text DEFAULT 'draft' NOT NULL,
|
||||||
|
`valid_until` integer,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
CONSTRAINT "proposals_status_check" CHECK("proposals"."status" in ('draft', 'sent', 'accepted', 'rejected')),
|
||||||
|
CONSTRAINT "proposals_amount_check" CHECK("proposals"."amount_minor" >= 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `proposals_owner_status_idx` ON `proposals` (`owner_user_id`,`status`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `subscriptions` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`owner_user_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`amount_minor` integer DEFAULT 0 NOT NULL,
|
||||||
|
`currency` text DEFAULT 'TRY' NOT NULL,
|
||||||
|
`billing_cycle` text DEFAULT 'monthly' NOT NULL,
|
||||||
|
`next_billing_date` text,
|
||||||
|
`status` text DEFAULT 'active' NOT NULL,
|
||||||
|
`category` text,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
CONSTRAINT "subscriptions_cycle_check" CHECK("subscriptions"."billing_cycle" in ('weekly', 'monthly', 'yearly')),
|
||||||
|
CONSTRAINT "subscriptions_status_check" CHECK("subscriptions"."status" in ('active', 'cancelled')),
|
||||||
|
CONSTRAINT "subscriptions_amount_check" CHECK("subscriptions"."amount_minor" >= 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `subscriptions_owner_status_idx` ON `subscriptions` (`owner_user_id`,`status`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `subscriptions_next_billing_date_idx` ON `subscriptions` (`next_billing_date`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `tasks` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`owner_user_id` text NOT NULL,
|
||||||
|
`client_id` text,
|
||||||
|
`project_id` text,
|
||||||
|
`source_journal_entry_id` text,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`description` text,
|
||||||
|
`status` text DEFAULT 'todo' NOT NULL,
|
||||||
|
`priority` text DEFAULT 'medium' NOT NULL,
|
||||||
|
`scheduled_date` text,
|
||||||
|
`due_at` integer,
|
||||||
|
`estimated_minutes` integer,
|
||||||
|
`actual_minutes` integer,
|
||||||
|
`ai_generated` integer DEFAULT false NOT NULL,
|
||||||
|
`is_public_to_client` integer DEFAULT false NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
FOREIGN KEY (`source_journal_entry_id`) REFERENCES `journal_entries`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
CONSTRAINT "tasks_status_check" CHECK("tasks"."status" in ('todo', 'in_progress', 'done', 'cancelled')),
|
||||||
|
CONSTRAINT "tasks_priority_check" CHECK("tasks"."priority" in ('low', 'medium', 'high', 'urgent')),
|
||||||
|
CONSTRAINT "tasks_estimated_minutes_check" CHECK("tasks"."estimated_minutes" is null or "tasks"."estimated_minutes" >= 0),
|
||||||
|
CONSTRAINT "tasks_actual_minutes_check" CHECK("tasks"."actual_minutes" is null or "tasks"."actual_minutes" >= 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `tasks_owner_user_id_idx` ON `tasks` (`owner_user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `tasks_owner_status_idx` ON `tasks` (`owner_user_id`,`status`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `tasks_project_id_idx` ON `tasks` (`project_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `tasks_client_id_idx` ON `tasks` (`client_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `tasks_due_at_idx` ON `tasks` (`due_at`);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,13 @@
|
|||||||
"when": 1784205329112,
|
"when": 1784205329112,
|
||||||
"tag": "0002_mighty_korg",
|
"tag": "0002_mighty_korg",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1784208712933,
|
||||||
|
"tag": "0003_chief_excalibur",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
PortalInvitationStatus,
|
PortalInvitationStatus,
|
||||||
SetupStatus,
|
SetupStatus,
|
||||||
UserRole,
|
UserRole,
|
||||||
} from "@/server/auth/types";
|
} from "../../auth/types";
|
||||||
|
|
||||||
const nowMs = sql`(cast(unixepoch('subsecond') * 1000 as integer))`;
|
const nowMs = sql`(cast(unixepoch('subsecond') * 1000 as integer))`;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,474 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
check,
|
||||||
|
index,
|
||||||
|
integer,
|
||||||
|
sqliteTable,
|
||||||
|
text,
|
||||||
|
uniqueIndex,
|
||||||
|
} from "drizzle-orm/sqlite-core";
|
||||||
|
import {
|
||||||
|
type CalendarEventType,
|
||||||
|
type ChatMessageRole,
|
||||||
|
type ClientActivityType,
|
||||||
|
type ClientPipelineStage,
|
||||||
|
type ClientStatus,
|
||||||
|
type ContractStatus,
|
||||||
|
type FinanceTransactionType,
|
||||||
|
type InvoiceStatus,
|
||||||
|
type PaymentStatus,
|
||||||
|
type PlanningSectionCategory,
|
||||||
|
type ProjectProgressType,
|
||||||
|
type ProjectStatus,
|
||||||
|
type ProjectType,
|
||||||
|
type ProposalStatus,
|
||||||
|
type RevisionStatus,
|
||||||
|
type SubscriptionBillingCycle,
|
||||||
|
type SubscriptionStatus,
|
||||||
|
type TaskPriority,
|
||||||
|
type TaskStatus,
|
||||||
|
} from "../../domain/types";
|
||||||
|
import { user } from "./auth";
|
||||||
|
|
||||||
|
const nowMs = sql`(cast(unixepoch('subsecond') * 1000 as integer))`;
|
||||||
|
|
||||||
|
export const clients = sqliteTable(
|
||||||
|
"clients",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
ownerUserId: text("owner_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
authUserId: text("auth_user_id").references(() => user.id, { onDelete: "set null" }),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
companyName: text("company_name"),
|
||||||
|
email: text("email"),
|
||||||
|
phone: text("phone"),
|
||||||
|
website: text("website"),
|
||||||
|
status: text("status").$type<ClientStatus>().default("active").notNull(),
|
||||||
|
pipelineStage: text("pipeline_stage").$type<ClientPipelineStage>().default("lead").notNull(),
|
||||||
|
nextFollowUpDate: text("next_follow_up_date"),
|
||||||
|
notes: text("notes"),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||||
|
.default(nowMs)
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("clients_auth_user_id_unique").on(table.authUserId),
|
||||||
|
index("clients_owner_user_id_idx").on(table.ownerUserId),
|
||||||
|
index("clients_owner_status_idx").on(table.ownerUserId, table.status),
|
||||||
|
index("clients_owner_pipeline_idx").on(table.ownerUserId, table.pipelineStage),
|
||||||
|
index("clients_next_follow_up_date_idx").on(table.nextFollowUpDate),
|
||||||
|
check("clients_status_check", sql`${table.status} in ('active', 'paused', 'archived')`),
|
||||||
|
check(
|
||||||
|
"clients_pipeline_stage_check",
|
||||||
|
sql`${table.pipelineStage} in ('lead', 'contacted', 'proposal_sent', 'won', 'lost')`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const projects = sqliteTable(
|
||||||
|
"projects",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
ownerUserId: text("owner_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
clientId: text("client_id").references(() => clients.id, { onDelete: "set null" }),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
type: text("type").$type<ProjectType>().default("client_project").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
status: text("status").$type<ProjectStatus>().default("planning").notNull(),
|
||||||
|
startDate: text("start_date"),
|
||||||
|
dueDate: text("due_date"),
|
||||||
|
budgetAmountMinor: integer("budget_amount_minor"),
|
||||||
|
currency: text("currency").default("USD").notNull(),
|
||||||
|
progress: integer("progress").default(0).notNull(),
|
||||||
|
progressType: text("progress_type").$type<ProjectProgressType>().default("manual").notNull(),
|
||||||
|
revisionQuota: integer("revision_quota").default(0).notNull(),
|
||||||
|
legacyCoverImagePath: text("legacy_cover_image_path"),
|
||||||
|
coverImageAlt: text("cover_image_alt"),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||||
|
.default(nowMs)
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("projects_owner_user_id_idx").on(table.ownerUserId),
|
||||||
|
index("projects_owner_status_idx").on(table.ownerUserId, table.status),
|
||||||
|
index("projects_client_id_idx").on(table.clientId),
|
||||||
|
index("projects_due_date_idx").on(table.dueDate),
|
||||||
|
check("projects_type_check", sql`${table.type} in ('client_project', 'side_project')`),
|
||||||
|
check(
|
||||||
|
"projects_status_check",
|
||||||
|
sql`${table.status} in ('planning', 'active', 'paused', 'completed', 'cancelled')`,
|
||||||
|
),
|
||||||
|
check("projects_progress_check", sql`${table.progress} between 0 and 100`),
|
||||||
|
check("projects_revision_quota_check", sql`${table.revisionQuota} >= 0`),
|
||||||
|
check("projects_budget_check", sql`${table.budgetAmountMinor} is null or ${table.budgetAmountMinor} >= 0`),
|
||||||
|
check("projects_currency_check", sql`length(${table.currency}) = 3`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const journalEntries = sqliteTable(
|
||||||
|
"journal_entries",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
ownerUserId: text("owner_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
entryDate: text("entry_date").notNull(),
|
||||||
|
moodScore: integer("mood_score"),
|
||||||
|
energyScore: integer("energy_score"),
|
||||||
|
workSatisfactionScore: integer("work_satisfaction_score"),
|
||||||
|
moodLabel: text("mood_label"),
|
||||||
|
note: text("note"),
|
||||||
|
legacyAiMetadata: text("legacy_ai_metadata", { mode: "json" }).$type<Record<string, unknown> | null>(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||||
|
.default(nowMs)
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("journal_entries_owner_date_unique").on(table.ownerUserId, table.entryDate),
|
||||||
|
index("journal_entries_owner_date_idx").on(table.ownerUserId, table.entryDate),
|
||||||
|
check("journal_entries_mood_score_check", sql`${table.moodScore} is null or ${table.moodScore} between 1 and 5`),
|
||||||
|
check("journal_entries_energy_score_check", sql`${table.energyScore} is null or ${table.energyScore} between 1 and 5`),
|
||||||
|
check(
|
||||||
|
"journal_entries_work_score_check",
|
||||||
|
sql`${table.workSatisfactionScore} is null or ${table.workSatisfactionScore} between 1 and 5`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const tasks = sqliteTable(
|
||||||
|
"tasks",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
ownerUserId: text("owner_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
clientId: text("client_id").references(() => clients.id, { onDelete: "set null" }),
|
||||||
|
projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||||
|
sourceJournalEntryId: text("source_journal_entry_id").references(() => journalEntries.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
status: text("status").$type<TaskStatus>().default("todo").notNull(),
|
||||||
|
priority: text("priority").$type<TaskPriority>().default("medium").notNull(),
|
||||||
|
scheduledDate: text("scheduled_date"),
|
||||||
|
dueAt: integer("due_at", { mode: "timestamp_ms" }),
|
||||||
|
estimatedMinutes: integer("estimated_minutes"),
|
||||||
|
actualMinutes: integer("actual_minutes"),
|
||||||
|
aiGenerated: integer("ai_generated", { mode: "boolean" }).default(false).notNull(),
|
||||||
|
isPublicToClient: integer("is_public_to_client", { mode: "boolean" }).default(false).notNull(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||||
|
.default(nowMs)
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("tasks_owner_user_id_idx").on(table.ownerUserId),
|
||||||
|
index("tasks_owner_status_idx").on(table.ownerUserId, table.status),
|
||||||
|
index("tasks_project_id_idx").on(table.projectId),
|
||||||
|
index("tasks_client_id_idx").on(table.clientId),
|
||||||
|
index("tasks_due_at_idx").on(table.dueAt),
|
||||||
|
check("tasks_status_check", sql`${table.status} in ('todo', 'in_progress', 'done', 'cancelled')`),
|
||||||
|
check("tasks_priority_check", sql`${table.priority} in ('low', 'medium', 'high', 'urgent')`),
|
||||||
|
check("tasks_estimated_minutes_check", sql`${table.estimatedMinutes} is null or ${table.estimatedMinutes} >= 0`),
|
||||||
|
check("tasks_actual_minutes_check", sql`${table.actualMinutes} is null or ${table.actualMinutes} >= 0`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const calendarEvents = sqliteTable(
|
||||||
|
"calendar_events",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
ownerUserId: text("owner_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
clientId: text("client_id").references(() => clients.id, { onDelete: "set null" }),
|
||||||
|
projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||||
|
taskId: text("task_id").references(() => tasks.id, { onDelete: "set null" }),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
type: text("type").$type<CalendarEventType>().default("focus").notNull(),
|
||||||
|
startsAt: integer("starts_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
endsAt: integer("ends_at", { mode: "timestamp_ms" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||||
|
.default(nowMs)
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("calendar_events_owner_range_idx").on(table.ownerUserId, table.startsAt),
|
||||||
|
index("calendar_events_project_id_idx").on(table.projectId),
|
||||||
|
index("calendar_events_task_id_idx").on(table.taskId),
|
||||||
|
check("calendar_events_type_check", sql`${table.type} in ('meeting', 'focus', 'deadline', 'personal', 'finance')`),
|
||||||
|
check("calendar_events_time_check", sql`${table.endsAt} is null or ${table.endsAt} >= ${table.startsAt}`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const financeTransactions = sqliteTable(
|
||||||
|
"finance_transactions",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
ownerUserId: text("owner_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
clientId: text("client_id").references(() => clients.id, { onDelete: "set null" }),
|
||||||
|
projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||||
|
type: text("type").$type<FinanceTransactionType>().notNull(),
|
||||||
|
amountMinor: integer("amount_minor").notNull(),
|
||||||
|
currency: text("currency").default("USD").notNull(),
|
||||||
|
transactionDate: text("transaction_date").notNull(),
|
||||||
|
category: text("category"),
|
||||||
|
paymentStatus: text("payment_status").$type<PaymentStatus>().default("planned").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||||
|
.default(nowMs)
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("finance_transactions_owner_date_idx").on(table.ownerUserId, table.transactionDate),
|
||||||
|
index("finance_transactions_owner_type_idx").on(table.ownerUserId, table.type),
|
||||||
|
index("finance_transactions_client_id_idx").on(table.clientId),
|
||||||
|
index("finance_transactions_project_id_idx").on(table.projectId),
|
||||||
|
check("finance_transactions_type_check", sql`${table.type} in ('income', 'expense')`),
|
||||||
|
check("finance_transactions_amount_check", sql`${table.amountMinor} >= 0`),
|
||||||
|
check(
|
||||||
|
"finance_transactions_payment_status_check",
|
||||||
|
sql`${table.paymentStatus} in ('planned', 'pending', 'paid', 'cancelled')`,
|
||||||
|
),
|
||||||
|
check("finance_transactions_currency_check", sql`length(${table.currency}) = 3`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const clientActivities = sqliteTable(
|
||||||
|
"client_activities",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
ownerUserId: text("owner_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
clientId: text("client_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => clients.id, { onDelete: "cascade" }),
|
||||||
|
type: text("type").$type<ClientActivityType>().notNull(),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
content: text("content"),
|
||||||
|
activityDate: integer("activity_date", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("client_activities_owner_client_idx").on(table.ownerUserId, table.clientId),
|
||||||
|
index("client_activities_client_date_idx").on(table.clientId, table.activityDate),
|
||||||
|
check("client_activities_type_check", sql`${table.type} in ('note', 'call', 'meeting', 'email')`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const projectPlanningSections = sqliteTable(
|
||||||
|
"project_planning_sections",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
ownerUserId: text("owner_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
projectId: text("project_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
|
category: text("category").$type<PlanningSectionCategory>().notNull(),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
content: text("content"),
|
||||||
|
metadata: text("metadata", { mode: "json" }).$type<Record<string, unknown>>().default({}).notNull(),
|
||||||
|
sortOrder: integer("sort_order").default(0).notNull(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||||
|
.default(nowMs)
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("project_planning_sections_owner_idx").on(table.ownerUserId),
|
||||||
|
index("project_planning_sections_project_order_idx").on(table.projectId, table.sortOrder),
|
||||||
|
check(
|
||||||
|
"project_planning_sections_category_check",
|
||||||
|
sql`${table.category} in ('overview', 'problem', 'goal', 'audience', 'scope', 'design_system', 'color_palette', 'typography', 'assets', 'notes')`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const projectRevisions = sqliteTable(
|
||||||
|
"project_revisions",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
ownerUserId: text("owner_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
projectId: text("project_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
|
clientId: text("client_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => clients.id, { onDelete: "cascade" }),
|
||||||
|
requestedByUserId: text("requested_by_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
description: text("description").notNull(),
|
||||||
|
status: text("status").$type<RevisionStatus>().default("pending").notNull(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||||
|
.default(nowMs)
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("project_revisions_owner_project_idx").on(table.ownerUserId, table.projectId),
|
||||||
|
index("project_revisions_client_project_idx").on(table.clientId, table.projectId),
|
||||||
|
check(
|
||||||
|
"project_revisions_status_check",
|
||||||
|
sql`${table.status} in ('pending', 'in_progress', 'completed', 'rejected')`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const chatSessions = sqliteTable(
|
||||||
|
"chat_sessions",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
ownerUserId: text("owner_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||||
|
.default(nowMs)
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => [index("chat_sessions_owner_updated_idx").on(table.ownerUserId, table.updatedAt)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const chatMessages = sqliteTable(
|
||||||
|
"chat_messages",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
sessionId: text("session_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => chatSessions.id, { onDelete: "cascade" }),
|
||||||
|
role: text("role").$type<ChatMessageRole>().notNull(),
|
||||||
|
content: text("content").notNull(),
|
||||||
|
contextJournalEntryIds: text("context_journal_entry_ids", { mode: "json" })
|
||||||
|
.$type<string[]>()
|
||||||
|
.default([])
|
||||||
|
.notNull(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("chat_messages_session_created_idx").on(table.sessionId, table.createdAt),
|
||||||
|
check("chat_messages_role_check", sql`${table.role} in ('system', 'user', 'assistant', 'tool')`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const proposals = sqliteTable(
|
||||||
|
"proposals",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
ownerUserId: text("owner_user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
clientId: text("client_id").references(() => clients.id, { onDelete: "set null" }),
|
||||||
|
projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
amountMinor: integer("amount_minor").default(0).notNull(),
|
||||||
|
currency: text("currency").default("TRY").notNull(),
|
||||||
|
status: text("status").$type<ProposalStatus>().default("draft").notNull(),
|
||||||
|
validUntil: integer("valid_until", { mode: "timestamp_ms" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("proposals_owner_status_idx").on(table.ownerUserId, table.status),
|
||||||
|
check("proposals_status_check", sql`${table.status} in ('draft', 'sent', 'accepted', 'rejected')`),
|
||||||
|
check("proposals_amount_check", sql`${table.amountMinor} >= 0`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const contracts = sqliteTable(
|
||||||
|
"contracts",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
ownerUserId: text("owner_user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
proposalId: text("proposal_id").references(() => proposals.id, { onDelete: "set null" }),
|
||||||
|
clientId: text("client_id").references(() => clients.id, { onDelete: "set null" }),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
content: text("content"),
|
||||||
|
status: text("status").$type<ContractStatus>().default("draft").notNull(),
|
||||||
|
signedAt: integer("signed_at", { mode: "timestamp_ms" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("contracts_owner_status_idx").on(table.ownerUserId, table.status),
|
||||||
|
check("contracts_status_check", sql`${table.status} in ('draft', 'active', 'completed', 'cancelled')`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const invoices = sqliteTable(
|
||||||
|
"invoices",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
ownerUserId: text("owner_user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
clientId: text("client_id").references(() => clients.id, { onDelete: "set null" }),
|
||||||
|
projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||||
|
invoiceNumber: text("invoice_number").notNull(),
|
||||||
|
amountMinor: integer("amount_minor").default(0).notNull(),
|
||||||
|
taxBasisPoints: integer("tax_basis_points").default(0).notNull(),
|
||||||
|
currency: text("currency").default("TRY").notNull(),
|
||||||
|
status: text("status").$type<InvoiceStatus>().default("draft").notNull(),
|
||||||
|
issueDate: text("issue_date").notNull(),
|
||||||
|
dueDate: text("due_date"),
|
||||||
|
paidAt: integer("paid_at", { mode: "timestamp_ms" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("invoices_owner_number_unique").on(table.ownerUserId, table.invoiceNumber),
|
||||||
|
index("invoices_owner_status_idx").on(table.ownerUserId, table.status),
|
||||||
|
check("invoices_status_check", sql`${table.status} in ('draft', 'sent', 'paid', 'overdue', 'cancelled')`),
|
||||||
|
check("invoices_amount_check", sql`${table.amountMinor} >= 0`),
|
||||||
|
check("invoices_tax_check", sql`${table.taxBasisPoints} between 0 and 10000`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const subscriptions = sqliteTable(
|
||||||
|
"subscriptions",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
ownerUserId: text("owner_user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
amountMinor: integer("amount_minor").default(0).notNull(),
|
||||||
|
currency: text("currency").default("TRY").notNull(),
|
||||||
|
billingCycle: text("billing_cycle").$type<SubscriptionBillingCycle>().default("monthly").notNull(),
|
||||||
|
nextBillingDate: text("next_billing_date"),
|
||||||
|
status: text("status").$type<SubscriptionStatus>().default("active").notNull(),
|
||||||
|
category: text("category"),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("subscriptions_owner_status_idx").on(table.ownerUserId, table.status),
|
||||||
|
index("subscriptions_next_billing_date_idx").on(table.nextBillingDate),
|
||||||
|
check("subscriptions_cycle_check", sql`${table.billingCycle} in ('weekly', 'monthly', 'yearly')`),
|
||||||
|
check("subscriptions_status_check", sql`${table.status} in ('active', 'cancelled')`),
|
||||||
|
check("subscriptions_amount_check", sql`${table.amountMinor} >= 0`),
|
||||||
|
],
|
||||||
|
);
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from "./auth";
|
export * from "./auth";
|
||||||
|
export * from "./domain";
|
||||||
export * from "./runtime";
|
export * from "./runtime";
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { UserRole } from "../auth/types";
|
||||||
|
import { DomainError } from "./errors";
|
||||||
|
|
||||||
|
export type DomainActor = {
|
||||||
|
authUserId: string;
|
||||||
|
role: UserRole;
|
||||||
|
clientId: string | null;
|
||||||
|
disabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OwnerScope = {
|
||||||
|
kind: "owner";
|
||||||
|
ownerUserId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClientScope = {
|
||||||
|
kind: "client";
|
||||||
|
authUserId: string;
|
||||||
|
clientId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function requireOwnerScope(actor: DomainActor): OwnerScope {
|
||||||
|
assertEnabledActor(actor);
|
||||||
|
|
||||||
|
if (actor.role !== "freelancer") {
|
||||||
|
throw new DomainError("FORBIDDEN", "Bu işlem yalnızca instance sahibi tarafından yapılabilir.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: "owner", ownerUserId: actor.authUserId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireClientScope(actor: DomainActor): ClientScope {
|
||||||
|
assertEnabledActor(actor);
|
||||||
|
|
||||||
|
if (actor.role !== "client" || !actor.clientId) {
|
||||||
|
throw new DomainError("FORBIDDEN", "Geçerli bir müşteri portal hesabı gerekli.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: "client", authUserId: actor.authUserId, clientId: actor.clientId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertEnabledActor(actor: DomainActor): void {
|
||||||
|
if (actor.disabled) {
|
||||||
|
throw new DomainError("FORBIDDEN", "Kullanıcı hesabı devre dışı.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
||||||
|
import * as schema from "../db/schema";
|
||||||
|
|
||||||
|
export type DomainDatabase = BetterSQLite3Database<typeof schema>;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export type DomainErrorCode =
|
||||||
|
| "VALIDATION_ERROR"
|
||||||
|
| "UNAUTHENTICATED"
|
||||||
|
| "FORBIDDEN"
|
||||||
|
| "NOT_FOUND"
|
||||||
|
| "CONFLICT"
|
||||||
|
| "INVARIANT_VIOLATION";
|
||||||
|
|
||||||
|
const statusByCode: Record<DomainErrorCode, number> = {
|
||||||
|
VALIDATION_ERROR: 400,
|
||||||
|
UNAUTHENTICATED: 401,
|
||||||
|
FORBIDDEN: 403,
|
||||||
|
NOT_FOUND: 404,
|
||||||
|
CONFLICT: 409,
|
||||||
|
INVARIANT_VIOLATION: 422,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class DomainError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly code: DomainErrorCode,
|
||||||
|
message: string,
|
||||||
|
public readonly details?: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "DomainError";
|
||||||
|
this.status = statusByCode[code];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notFound(resource = "Kaynak"): DomainError {
|
||||||
|
return new DomainError("NOT_FOUND", `${resource} bulunamadı.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function conflict(message: string): DomainError {
|
||||||
|
return new DomainError("CONFLICT", message);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
export type IdGenerator = () => string;
|
||||||
|
|
||||||
|
export const generateId: IdGenerator = randomUUID;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
export const clientStatuses = ["active", "paused", "archived"] as const;
|
||||||
|
export const clientPipelineStages = ["lead", "contacted", "proposal_sent", "won", "lost"] as const;
|
||||||
|
export const clientActivityTypes = ["note", "call", "meeting", "email"] as const;
|
||||||
|
export const projectTypes = ["client_project", "side_project"] as const;
|
||||||
|
export const projectStatuses = ["planning", "active", "paused", "completed", "cancelled"] as const;
|
||||||
|
export const projectProgressTypes = ["manual", "auto"] as const;
|
||||||
|
export const taskStatuses = ["todo", "in_progress", "done", "cancelled"] as const;
|
||||||
|
export const taskPriorities = ["low", "medium", "high", "urgent"] as const;
|
||||||
|
export const calendarEventTypes = ["meeting", "focus", "deadline", "personal", "finance"] as const;
|
||||||
|
export const financeTransactionTypes = ["income", "expense"] as const;
|
||||||
|
export const paymentStatuses = ["planned", "pending", "paid", "cancelled"] as const;
|
||||||
|
export const planningSectionCategories = [
|
||||||
|
"overview",
|
||||||
|
"problem",
|
||||||
|
"goal",
|
||||||
|
"audience",
|
||||||
|
"scope",
|
||||||
|
"design_system",
|
||||||
|
"color_palette",
|
||||||
|
"typography",
|
||||||
|
"assets",
|
||||||
|
"notes",
|
||||||
|
] as const;
|
||||||
|
export const revisionStatuses = ["pending", "in_progress", "completed", "rejected"] as const;
|
||||||
|
export const chatMessageRoles = ["system", "user", "assistant", "tool"] as const;
|
||||||
|
export const proposalStatuses = ["draft", "sent", "accepted", "rejected"] as const;
|
||||||
|
export const contractStatuses = ["draft", "active", "completed", "cancelled"] as const;
|
||||||
|
export const invoiceStatuses = ["draft", "sent", "paid", "overdue", "cancelled"] as const;
|
||||||
|
export const subscriptionBillingCycles = ["weekly", "monthly", "yearly"] as const;
|
||||||
|
export const subscriptionStatuses = ["active", "cancelled"] as const;
|
||||||
|
|
||||||
|
export type ClientStatus = (typeof clientStatuses)[number];
|
||||||
|
export type ClientPipelineStage = (typeof clientPipelineStages)[number];
|
||||||
|
export type ClientActivityType = (typeof clientActivityTypes)[number];
|
||||||
|
export type ProjectType = (typeof projectTypes)[number];
|
||||||
|
export type ProjectStatus = (typeof projectStatuses)[number];
|
||||||
|
export type ProjectProgressType = (typeof projectProgressTypes)[number];
|
||||||
|
export type TaskStatus = (typeof taskStatuses)[number];
|
||||||
|
export type TaskPriority = (typeof taskPriorities)[number];
|
||||||
|
export type CalendarEventType = (typeof calendarEventTypes)[number];
|
||||||
|
export type FinanceTransactionType = (typeof financeTransactionTypes)[number];
|
||||||
|
export type PaymentStatus = (typeof paymentStatuses)[number];
|
||||||
|
export type PlanningSectionCategory = (typeof planningSectionCategories)[number];
|
||||||
|
export type RevisionStatus = (typeof revisionStatuses)[number];
|
||||||
|
export type ChatMessageRole = (typeof chatMessageRoles)[number];
|
||||||
|
export type ProposalStatus = (typeof proposalStatuses)[number];
|
||||||
|
export type ContractStatus = (typeof contractStatuses)[number];
|
||||||
|
export type InvoiceStatus = (typeof invoiceStatuses)[number];
|
||||||
|
export type SubscriptionBillingCycle = (typeof subscriptionBillingCycles)[number];
|
||||||
|
export type SubscriptionStatus = (typeof subscriptionStatuses)[number];
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
calendarEventTypes,
|
||||||
|
chatMessageRoles,
|
||||||
|
clientActivityTypes,
|
||||||
|
clientPipelineStages,
|
||||||
|
clientStatuses,
|
||||||
|
contractStatuses,
|
||||||
|
financeTransactionTypes,
|
||||||
|
invoiceStatuses,
|
||||||
|
paymentStatuses,
|
||||||
|
planningSectionCategories,
|
||||||
|
projectProgressTypes,
|
||||||
|
projectStatuses,
|
||||||
|
projectTypes,
|
||||||
|
proposalStatuses,
|
||||||
|
revisionStatuses,
|
||||||
|
subscriptionBillingCycles,
|
||||||
|
subscriptionStatuses,
|
||||||
|
taskPriorities,
|
||||||
|
taskStatuses,
|
||||||
|
} from "./types";
|
||||||
|
import { DomainError } from "./errors";
|
||||||
|
|
||||||
|
export const resourceIdSchema = z.string().trim().min(1).max(128);
|
||||||
|
export const businessDateSchema = z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/, "Tarih YYYY-MM-DD formatında olmalıdır.");
|
||||||
|
export const currencySchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.length(3)
|
||||||
|
.transform((value) => value.toUpperCase());
|
||||||
|
export const minorAmountSchema = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
|
||||||
|
|
||||||
|
const optionalText = (max: number) => z.string().trim().max(max).nullable().optional();
|
||||||
|
const optionalId = resourceIdSchema.nullable().optional();
|
||||||
|
const optionalDate = businessDateSchema.nullable().optional();
|
||||||
|
|
||||||
|
export const clientCreateSchema = z.object({
|
||||||
|
id: resourceIdSchema.optional(),
|
||||||
|
name: z.string().trim().min(1).max(160),
|
||||||
|
companyName: optionalText(160),
|
||||||
|
email: z.email().nullable().optional(),
|
||||||
|
phone: optionalText(40),
|
||||||
|
website: z.url().nullable().optional(),
|
||||||
|
status: z.enum(clientStatuses).default("active"),
|
||||||
|
pipelineStage: z.enum(clientPipelineStages).default("lead"),
|
||||||
|
nextFollowUpDate: optionalDate,
|
||||||
|
notes: optionalText(10_000),
|
||||||
|
});
|
||||||
|
export const clientUpdateSchema = clientCreateSchema.omit({ id: true }).partial();
|
||||||
|
|
||||||
|
export const clientActivityCreateSchema = z.object({
|
||||||
|
id: resourceIdSchema.optional(),
|
||||||
|
clientId: resourceIdSchema,
|
||||||
|
type: z.enum(clientActivityTypes),
|
||||||
|
title: z.string().trim().min(1).max(200),
|
||||||
|
content: optionalText(10_000),
|
||||||
|
activityDate: z.date().default(() => new Date()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const projectCreateSchema = z.object({
|
||||||
|
id: resourceIdSchema.optional(),
|
||||||
|
clientId: optionalId,
|
||||||
|
name: z.string().trim().min(1).max(200),
|
||||||
|
type: z.enum(projectTypes).default("client_project"),
|
||||||
|
description: optionalText(20_000),
|
||||||
|
status: z.enum(projectStatuses).default("planning"),
|
||||||
|
startDate: optionalDate,
|
||||||
|
dueDate: optionalDate,
|
||||||
|
budgetAmountMinor: minorAmountSchema.nullable().optional(),
|
||||||
|
currency: currencySchema.default("USD"),
|
||||||
|
progress: z.number().int().min(0).max(100).default(0),
|
||||||
|
progressType: z.enum(projectProgressTypes).default("manual"),
|
||||||
|
revisionQuota: z.number().int().min(0).max(10_000).default(0),
|
||||||
|
legacyCoverImagePath: optionalText(1_000),
|
||||||
|
coverImageAlt: optionalText(500),
|
||||||
|
});
|
||||||
|
export const projectUpdateSchema = projectCreateSchema.omit({ id: true }).partial();
|
||||||
|
|
||||||
|
export const taskCreateSchema = z.object({
|
||||||
|
id: resourceIdSchema.optional(),
|
||||||
|
clientId: optionalId,
|
||||||
|
projectId: optionalId,
|
||||||
|
sourceJournalEntryId: optionalId,
|
||||||
|
title: z.string().trim().min(1).max(300),
|
||||||
|
description: optionalText(20_000),
|
||||||
|
status: z.enum(taskStatuses).default("todo"),
|
||||||
|
priority: z.enum(taskPriorities).default("medium"),
|
||||||
|
scheduledDate: optionalDate,
|
||||||
|
dueAt: z.date().nullable().optional(),
|
||||||
|
estimatedMinutes: z.number().int().min(0).nullable().optional(),
|
||||||
|
actualMinutes: z.number().int().min(0).nullable().optional(),
|
||||||
|
aiGenerated: z.boolean().default(false),
|
||||||
|
isPublicToClient: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
export const taskUpdateSchema = taskCreateSchema.omit({ id: true }).partial();
|
||||||
|
|
||||||
|
const calendarEventBaseSchema = z.object({
|
||||||
|
id: resourceIdSchema.optional(),
|
||||||
|
clientId: optionalId,
|
||||||
|
projectId: optionalId,
|
||||||
|
taskId: optionalId,
|
||||||
|
title: z.string().trim().min(1).max(300),
|
||||||
|
description: optionalText(20_000),
|
||||||
|
type: z.enum(calendarEventTypes).default("focus"),
|
||||||
|
startsAt: z.date(),
|
||||||
|
endsAt: z.date().nullable().optional(),
|
||||||
|
});
|
||||||
|
export const calendarEventCreateSchema = calendarEventBaseSchema
|
||||||
|
.refine((value) => !value.endsAt || value.endsAt >= value.startsAt, {
|
||||||
|
message: "Bitiş zamanı başlangıç zamanından önce olamaz.",
|
||||||
|
path: ["endsAt"],
|
||||||
|
});
|
||||||
|
export const calendarEventUpdateSchema = calendarEventBaseSchema.omit({ id: true }).partial();
|
||||||
|
|
||||||
|
export const financeTransactionCreateSchema = z.object({
|
||||||
|
id: resourceIdSchema.optional(),
|
||||||
|
clientId: optionalId,
|
||||||
|
projectId: optionalId,
|
||||||
|
type: z.enum(financeTransactionTypes),
|
||||||
|
amountMinor: minorAmountSchema,
|
||||||
|
currency: currencySchema.default("USD"),
|
||||||
|
transactionDate: businessDateSchema,
|
||||||
|
category: optionalText(160),
|
||||||
|
paymentStatus: z.enum(paymentStatuses).default("planned"),
|
||||||
|
description: optionalText(10_000),
|
||||||
|
});
|
||||||
|
export const financeTransactionUpdateSchema = financeTransactionCreateSchema.omit({ id: true }).partial();
|
||||||
|
|
||||||
|
export const journalEntrySchema = z.object({
|
||||||
|
id: resourceIdSchema.optional(),
|
||||||
|
entryDate: businessDateSchema,
|
||||||
|
moodScore: z.number().int().min(1).max(5).nullable().optional(),
|
||||||
|
energyScore: z.number().int().min(1).max(5).nullable().optional(),
|
||||||
|
workSatisfactionScore: z.number().int().min(1).max(5).nullable().optional(),
|
||||||
|
moodLabel: optionalText(80),
|
||||||
|
note: optionalText(30_000),
|
||||||
|
legacyAiMetadata: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const planningSectionCreateSchema = z.object({
|
||||||
|
id: resourceIdSchema.optional(),
|
||||||
|
projectId: resourceIdSchema,
|
||||||
|
category: z.enum(planningSectionCategories),
|
||||||
|
title: z.string().trim().min(1).max(300),
|
||||||
|
content: optionalText(50_000),
|
||||||
|
metadata: z.record(z.string(), z.unknown()).default({}),
|
||||||
|
sortOrder: z.number().int().min(0).default(0),
|
||||||
|
});
|
||||||
|
export const planningSectionUpdateSchema = planningSectionCreateSchema.omit({ id: true, projectId: true }).partial();
|
||||||
|
|
||||||
|
export const revisionCreateSchema = z.object({
|
||||||
|
id: resourceIdSchema.optional(),
|
||||||
|
projectId: resourceIdSchema,
|
||||||
|
description: z.string().trim().min(1).max(20_000),
|
||||||
|
});
|
||||||
|
export const revisionStatusSchema = z.enum(revisionStatuses);
|
||||||
|
|
||||||
|
export const chatSessionCreateSchema = z.object({
|
||||||
|
id: resourceIdSchema.optional(),
|
||||||
|
title: z.string().trim().min(1).max(300),
|
||||||
|
});
|
||||||
|
export const chatMessageCreateSchema = z.object({
|
||||||
|
id: resourceIdSchema.optional(),
|
||||||
|
sessionId: resourceIdSchema,
|
||||||
|
role: z.enum(chatMessageRoles),
|
||||||
|
content: z.string().trim().min(1).max(100_000),
|
||||||
|
contextJournalEntryIds: z.array(resourceIdSchema).max(100).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const proposalCreateSchema = z.object({
|
||||||
|
id: resourceIdSchema.optional(),
|
||||||
|
clientId: optionalId,
|
||||||
|
projectId: optionalId,
|
||||||
|
title: z.string().trim().min(1).max(300),
|
||||||
|
description: optionalText(30_000),
|
||||||
|
amountMinor: minorAmountSchema.default(0),
|
||||||
|
currency: currencySchema.default("TRY"),
|
||||||
|
status: z.enum(proposalStatuses).default("draft"),
|
||||||
|
validUntil: z.date().nullable().optional(),
|
||||||
|
});
|
||||||
|
export const contractCreateSchema = z.object({
|
||||||
|
id: resourceIdSchema.optional(),
|
||||||
|
proposalId: optionalId,
|
||||||
|
clientId: optionalId,
|
||||||
|
title: z.string().trim().min(1).max(300),
|
||||||
|
content: optionalText(100_000),
|
||||||
|
status: z.enum(contractStatuses).default("draft"),
|
||||||
|
signedAt: z.date().nullable().optional(),
|
||||||
|
});
|
||||||
|
export const invoiceCreateSchema = z.object({
|
||||||
|
id: resourceIdSchema.optional(),
|
||||||
|
clientId: optionalId,
|
||||||
|
projectId: optionalId,
|
||||||
|
invoiceNumber: z.string().trim().min(1).max(100),
|
||||||
|
amountMinor: minorAmountSchema.default(0),
|
||||||
|
taxBasisPoints: z.number().int().min(0).max(10_000).default(0),
|
||||||
|
currency: currencySchema.default("TRY"),
|
||||||
|
status: z.enum(invoiceStatuses).default("draft"),
|
||||||
|
issueDate: businessDateSchema,
|
||||||
|
dueDate: optionalDate,
|
||||||
|
paidAt: z.date().nullable().optional(),
|
||||||
|
});
|
||||||
|
export const subscriptionCreateSchema = z.object({
|
||||||
|
id: resourceIdSchema.optional(),
|
||||||
|
name: z.string().trim().min(1).max(300),
|
||||||
|
amountMinor: minorAmountSchema.default(0),
|
||||||
|
currency: currencySchema.default("TRY"),
|
||||||
|
billingCycle: z.enum(subscriptionBillingCycles).default("monthly"),
|
||||||
|
nextBillingDate: optionalDate,
|
||||||
|
status: z.enum(subscriptionStatuses).default("active"),
|
||||||
|
category: optionalText(160),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function parseDomainInput<TSchema extends z.ZodType>(
|
||||||
|
schema: TSchema,
|
||||||
|
input: unknown,
|
||||||
|
): z.output<TSchema> {
|
||||||
|
const result = schema.safeParse(input);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new DomainError("VALIDATION_ERROR", "Girilen bilgiler geçersiz.", {
|
||||||
|
fields: result.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { and, asc, count, desc, eq, ne, sql } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
calendarEvents,
|
||||||
|
chatMessages,
|
||||||
|
chatSessions,
|
||||||
|
clientActivities,
|
||||||
|
clients,
|
||||||
|
contracts,
|
||||||
|
financeTransactions,
|
||||||
|
invoices,
|
||||||
|
journalEntries,
|
||||||
|
projectPlanningSections,
|
||||||
|
projectRevisions,
|
||||||
|
projects,
|
||||||
|
proposals,
|
||||||
|
subscriptions,
|
||||||
|
tasks,
|
||||||
|
} from "../db/schema/domain";
|
||||||
|
import type { ClientScope, OwnerScope } from "../domain/actor";
|
||||||
|
import type { DomainDatabase } from "../domain/database";
|
||||||
|
|
||||||
|
export function createDomainRepositories(db: DomainDatabase) {
|
||||||
|
return {
|
||||||
|
clients: {
|
||||||
|
list: (scope: OwnerScope) =>
|
||||||
|
db.select().from(clients).where(eq(clients.ownerUserId, scope.ownerUserId)).orderBy(desc(clients.updatedAt)).all(),
|
||||||
|
get: (scope: OwnerScope, id: string) =>
|
||||||
|
db.select().from(clients).where(and(eq(clients.id, id), eq(clients.ownerUserId, scope.ownerUserId))).get(),
|
||||||
|
getByPortalScope: (scope: ClientScope) =>
|
||||||
|
db.select().from(clients).where(and(eq(clients.id, scope.clientId), eq(clients.authUserId, scope.authUserId))).get(),
|
||||||
|
create: (scope: OwnerScope, value: Omit<typeof clients.$inferInsert, "ownerUserId">) =>
|
||||||
|
db.insert(clients).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||||
|
update: (scope: OwnerScope, id: string, value: Partial<typeof clients.$inferInsert>) =>
|
||||||
|
db.update(clients).set(value).where(and(eq(clients.id, id), eq(clients.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||||
|
remove: (scope: OwnerScope, id: string) =>
|
||||||
|
db.delete(clients).where(and(eq(clients.id, id), eq(clients.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||||
|
listActivities: (scope: OwnerScope, clientId: string) =>
|
||||||
|
db.select().from(clientActivities).where(and(eq(clientActivities.ownerUserId, scope.ownerUserId), eq(clientActivities.clientId, clientId))).orderBy(desc(clientActivities.activityDate)).all(),
|
||||||
|
createActivity: (scope: OwnerScope, value: Omit<typeof clientActivities.$inferInsert, "ownerUserId">) =>
|
||||||
|
db.insert(clientActivities).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||||
|
},
|
||||||
|
projects: {
|
||||||
|
list: (scope: OwnerScope) =>
|
||||||
|
db.select().from(projects).where(eq(projects.ownerUserId, scope.ownerUserId)).orderBy(desc(projects.updatedAt)).all(),
|
||||||
|
get: (scope: OwnerScope, id: string) =>
|
||||||
|
db.select().from(projects).where(and(eq(projects.id, id), eq(projects.ownerUserId, scope.ownerUserId))).get(),
|
||||||
|
getForClient: (scope: ClientScope, id: string) =>
|
||||||
|
db.select().from(projects).where(and(eq(projects.id, id), eq(projects.clientId, scope.clientId))).get(),
|
||||||
|
listForClient: (scope: ClientScope) =>
|
||||||
|
db.select().from(projects).where(eq(projects.clientId, scope.clientId)).orderBy(desc(projects.updatedAt)).all(),
|
||||||
|
create: (scope: OwnerScope, value: Omit<typeof projects.$inferInsert, "ownerUserId">) =>
|
||||||
|
db.insert(projects).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||||
|
update: (scope: OwnerScope, id: string, value: Partial<typeof projects.$inferInsert>) =>
|
||||||
|
db.update(projects).set(value).where(and(eq(projects.id, id), eq(projects.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||||
|
remove: (scope: OwnerScope, id: string) =>
|
||||||
|
db.delete(projects).where(and(eq(projects.id, id), eq(projects.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
list: (scope: OwnerScope) =>
|
||||||
|
db.select().from(tasks).where(eq(tasks.ownerUserId, scope.ownerUserId)).orderBy(desc(tasks.updatedAt)).all(),
|
||||||
|
get: (scope: OwnerScope, id: string) =>
|
||||||
|
db.select().from(tasks).where(and(eq(tasks.id, id), eq(tasks.ownerUserId, scope.ownerUserId))).get(),
|
||||||
|
listPublicForClient: (scope: ClientScope, projectId?: string) =>
|
||||||
|
db.select().from(tasks).where(and(eq(tasks.clientId, scope.clientId), eq(tasks.isPublicToClient, true), projectId ? eq(tasks.projectId, projectId) : undefined)).orderBy(asc(tasks.dueAt)).all(),
|
||||||
|
create: (scope: OwnerScope, value: Omit<typeof tasks.$inferInsert, "ownerUserId">) =>
|
||||||
|
db.insert(tasks).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||||
|
update: (scope: OwnerScope, id: string, value: Partial<typeof tasks.$inferInsert>) =>
|
||||||
|
db.update(tasks).set(value).where(and(eq(tasks.id, id), eq(tasks.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||||
|
remove: (scope: OwnerScope, id: string) =>
|
||||||
|
db.delete(tasks).where(and(eq(tasks.id, id), eq(tasks.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||||
|
progressCounts: (scope: OwnerScope, projectId: string) =>
|
||||||
|
db.select({ total: count(), done: sql<number>`sum(case when ${tasks.status} = 'done' then 1 else 0 end)` }).from(tasks).where(and(eq(tasks.ownerUserId, scope.ownerUserId), eq(tasks.projectId, projectId), ne(tasks.status, "cancelled"))).get(),
|
||||||
|
},
|
||||||
|
planning: {
|
||||||
|
list: (scope: OwnerScope, projectId: string) =>
|
||||||
|
db.select().from(projectPlanningSections).where(and(eq(projectPlanningSections.ownerUserId, scope.ownerUserId), eq(projectPlanningSections.projectId, projectId))).orderBy(asc(projectPlanningSections.sortOrder)).all(),
|
||||||
|
listForClient: (scope: ClientScope, projectId: string) =>
|
||||||
|
db.select({ section: projectPlanningSections }).from(projectPlanningSections).innerJoin(projects, eq(projectPlanningSections.projectId, projects.id)).where(and(eq(projectPlanningSections.projectId, projectId), eq(projects.clientId, scope.clientId))).orderBy(asc(projectPlanningSections.sortOrder)).all().map(({ section }) => section),
|
||||||
|
create: (scope: OwnerScope, value: Omit<typeof projectPlanningSections.$inferInsert, "ownerUserId">) =>
|
||||||
|
db.insert(projectPlanningSections).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||||
|
get: (scope: OwnerScope, id: string) => db.select().from(projectPlanningSections).where(and(eq(projectPlanningSections.id, id), eq(projectPlanningSections.ownerUserId, scope.ownerUserId))).get(),
|
||||||
|
update: (scope: OwnerScope, id: string, value: Partial<typeof projectPlanningSections.$inferInsert>) => db.update(projectPlanningSections).set(value).where(and(eq(projectPlanningSections.id, id), eq(projectPlanningSections.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||||
|
remove: (scope: OwnerScope, id: string) => db.delete(projectPlanningSections).where(and(eq(projectPlanningSections.id, id), eq(projectPlanningSections.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||||
|
},
|
||||||
|
calendar: {
|
||||||
|
list: (scope: OwnerScope) => db.select().from(calendarEvents).where(eq(calendarEvents.ownerUserId, scope.ownerUserId)).orderBy(asc(calendarEvents.startsAt)).all(),
|
||||||
|
get: (scope: OwnerScope, id: string) => db.select().from(calendarEvents).where(and(eq(calendarEvents.id, id), eq(calendarEvents.ownerUserId, scope.ownerUserId))).get(),
|
||||||
|
create: (scope: OwnerScope, value: Omit<typeof calendarEvents.$inferInsert, "ownerUserId">) => db.insert(calendarEvents).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||||
|
update: (scope: OwnerScope, id: string, value: Partial<typeof calendarEvents.$inferInsert>) => db.update(calendarEvents).set(value).where(and(eq(calendarEvents.id, id), eq(calendarEvents.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||||
|
remove: (scope: OwnerScope, id: string) => db.delete(calendarEvents).where(and(eq(calendarEvents.id, id), eq(calendarEvents.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||||
|
},
|
||||||
|
finance: {
|
||||||
|
list: (scope: OwnerScope) => db.select().from(financeTransactions).where(eq(financeTransactions.ownerUserId, scope.ownerUserId)).orderBy(desc(financeTransactions.transactionDate)).all(),
|
||||||
|
get: (scope: OwnerScope, id: string) => db.select().from(financeTransactions).where(and(eq(financeTransactions.id, id), eq(financeTransactions.ownerUserId, scope.ownerUserId))).get(),
|
||||||
|
create: (scope: OwnerScope, value: Omit<typeof financeTransactions.$inferInsert, "ownerUserId">) => db.insert(financeTransactions).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||||
|
update: (scope: OwnerScope, id: string, value: Partial<typeof financeTransactions.$inferInsert>) => db.update(financeTransactions).set(value).where(and(eq(financeTransactions.id, id), eq(financeTransactions.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||||
|
remove: (scope: OwnerScope, id: string) => db.delete(financeTransactions).where(and(eq(financeTransactions.id, id), eq(financeTransactions.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||||
|
},
|
||||||
|
journal: {
|
||||||
|
list: (scope: OwnerScope) => db.select().from(journalEntries).where(eq(journalEntries.ownerUserId, scope.ownerUserId)).orderBy(desc(journalEntries.entryDate)).all(),
|
||||||
|
getByDate: (scope: OwnerScope, entryDate: string) => db.select().from(journalEntries).where(and(eq(journalEntries.ownerUserId, scope.ownerUserId), eq(journalEntries.entryDate, entryDate))).get(),
|
||||||
|
get: (scope: OwnerScope, id: string) => db.select().from(journalEntries).where(and(eq(journalEntries.id, id), eq(journalEntries.ownerUserId, scope.ownerUserId))).get(),
|
||||||
|
create: (scope: OwnerScope, value: Omit<typeof journalEntries.$inferInsert, "ownerUserId">) => db.insert(journalEntries).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||||
|
updateByDate: (scope: OwnerScope, entryDate: string, value: Partial<typeof journalEntries.$inferInsert>) => db.update(journalEntries).set(value).where(and(eq(journalEntries.ownerUserId, scope.ownerUserId), eq(journalEntries.entryDate, entryDate))).returning().get(),
|
||||||
|
remove: (scope: OwnerScope, id: string) => db.delete(journalEntries).where(and(eq(journalEntries.id, id), eq(journalEntries.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||||
|
},
|
||||||
|
revisions: {
|
||||||
|
list: (scope: OwnerScope, projectId: string) => db.select().from(projectRevisions).where(and(eq(projectRevisions.ownerUserId, scope.ownerUserId), eq(projectRevisions.projectId, projectId))).orderBy(desc(projectRevisions.createdAt)).all(),
|
||||||
|
updateStatus: (scope: OwnerScope, id: string, status: typeof projectRevisions.$inferInsert.status) => db.update(projectRevisions).set({ status }).where(and(eq(projectRevisions.id, id), eq(projectRevisions.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||||
|
listForClient: (scope: ClientScope, projectId: string) => db.select().from(projectRevisions).where(and(eq(projectRevisions.clientId, scope.clientId), eq(projectRevisions.projectId, projectId))).orderBy(desc(projectRevisions.createdAt)).all(),
|
||||||
|
},
|
||||||
|
chat: {
|
||||||
|
listSessions: (scope: OwnerScope) => db.select().from(chatSessions).where(eq(chatSessions.ownerUserId, scope.ownerUserId)).orderBy(desc(chatSessions.updatedAt)).all(),
|
||||||
|
getSession: (scope: OwnerScope, id: string) => db.select().from(chatSessions).where(and(eq(chatSessions.id, id), eq(chatSessions.ownerUserId, scope.ownerUserId))).get(),
|
||||||
|
createSession: (scope: OwnerScope, value: Omit<typeof chatSessions.$inferInsert, "ownerUserId">) => db.insert(chatSessions).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||||
|
listMessages: (scope: OwnerScope, sessionId: string) => db.select({ message: chatMessages }).from(chatMessages).innerJoin(chatSessions, eq(chatMessages.sessionId, chatSessions.id)).where(and(eq(chatMessages.sessionId, sessionId), eq(chatSessions.ownerUserId, scope.ownerUserId))).orderBy(asc(chatMessages.createdAt)).all().map(({ message }) => message),
|
||||||
|
createMessage: (value: typeof chatMessages.$inferInsert) => db.insert(chatMessages).values(value).returning().get(),
|
||||||
|
},
|
||||||
|
business: {
|
||||||
|
getProposal: (scope: OwnerScope, id: string) => db.select().from(proposals).where(and(eq(proposals.id, id), eq(proposals.ownerUserId, scope.ownerUserId))).get(),
|
||||||
|
createProposal: (scope: OwnerScope, value: Omit<typeof proposals.$inferInsert, "ownerUserId">) => db.insert(proposals).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||||
|
createContract: (scope: OwnerScope, value: Omit<typeof contracts.$inferInsert, "ownerUserId">) => db.insert(contracts).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||||
|
createInvoice: (scope: OwnerScope, value: Omit<typeof invoices.$inferInsert, "ownerUserId">) => db.insert(invoices).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||||
|
createSubscription: (scope: OwnerScope, value: Omit<typeof subscriptions.$inferInsert, "ownerUserId">) => db.insert(subscriptions).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||||
|
},
|
||||||
|
analytics: {
|
||||||
|
summary: (scope: OwnerScope) => db.select({
|
||||||
|
incomeMinor: sql<number>`coalesce(sum(case when ${financeTransactions.type} = 'income' and ${financeTransactions.paymentStatus} = 'paid' then ${financeTransactions.amountMinor} else 0 end), 0)`,
|
||||||
|
expenseMinor: sql<number>`coalesce(sum(case when ${financeTransactions.type} = 'expense' and ${financeTransactions.paymentStatus} = 'paid' then ${financeTransactions.amountMinor} else 0 end), 0)`,
|
||||||
|
plannedMinor: sql<number>`coalesce(sum(case when ${financeTransactions.paymentStatus} in ('planned', 'pending') then ${financeTransactions.amountMinor} else 0 end), 0)`,
|
||||||
|
}).from(financeTransactions).where(eq(financeTransactions.ownerUserId, scope.ownerUserId)).get(),
|
||||||
|
projectStatusCounts: (scope: OwnerScope) => db.select({ status: projects.status, value: count() }).from(projects).where(eq(projects.ownerUserId, scope.ownerUserId)).groupBy(projects.status).all(),
|
||||||
|
taskStatusCounts: (scope: OwnerScope) => db.select({ status: tasks.status, value: count() }).from(tasks).where(eq(tasks.ownerUserId, scope.ownerUserId)).groupBy(tasks.status).all(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DomainRepositories = ReturnType<typeof createDomainRepositories>;
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
import { and, count, eq, inArray, ne } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
chatSessions,
|
||||||
|
journalEntries,
|
||||||
|
projectRevisions,
|
||||||
|
projects,
|
||||||
|
} from "../db/schema/domain";
|
||||||
|
import { requireClientScope, requireOwnerScope, type DomainActor, type OwnerScope } from "../domain/actor";
|
||||||
|
import type { DomainDatabase } from "../domain/database";
|
||||||
|
import { conflict, DomainError, notFound } from "../domain/errors";
|
||||||
|
import { generateId, type IdGenerator } from "../domain/id";
|
||||||
|
import {
|
||||||
|
calendarEventCreateSchema,
|
||||||
|
chatMessageCreateSchema,
|
||||||
|
chatSessionCreateSchema,
|
||||||
|
clientActivityCreateSchema,
|
||||||
|
clientCreateSchema,
|
||||||
|
clientUpdateSchema,
|
||||||
|
contractCreateSchema,
|
||||||
|
financeTransactionCreateSchema,
|
||||||
|
financeTransactionUpdateSchema,
|
||||||
|
invoiceCreateSchema,
|
||||||
|
journalEntrySchema,
|
||||||
|
parseDomainInput,
|
||||||
|
planningSectionCreateSchema,
|
||||||
|
planningSectionUpdateSchema,
|
||||||
|
projectCreateSchema,
|
||||||
|
projectUpdateSchema,
|
||||||
|
proposalCreateSchema,
|
||||||
|
revisionCreateSchema,
|
||||||
|
revisionStatusSchema,
|
||||||
|
subscriptionCreateSchema,
|
||||||
|
taskCreateSchema,
|
||||||
|
taskUpdateSchema,
|
||||||
|
calendarEventUpdateSchema,
|
||||||
|
} from "../domain/validation";
|
||||||
|
import { createDomainRepositories, type DomainRepositories } from "../repositories/domain";
|
||||||
|
|
||||||
|
export class DomainService {
|
||||||
|
readonly repositories: DomainRepositories;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly db: DomainDatabase,
|
||||||
|
private readonly id: IdGenerator = generateId,
|
||||||
|
) {
|
||||||
|
this.repositories = createDomainRepositories(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
listClients(actor: DomainActor) {
|
||||||
|
return this.repositories.clients.list(requireOwnerScope(actor));
|
||||||
|
}
|
||||||
|
|
||||||
|
getClient(actor: DomainActor, id: string) {
|
||||||
|
if (actor.role === "client") {
|
||||||
|
const scope = requireClientScope(actor);
|
||||||
|
if (scope.clientId !== id) throw notFound("Müşteri");
|
||||||
|
return this.repositories.clients.getByPortalScope(scope) ?? this.throwNotFound("Müşteri");
|
||||||
|
}
|
||||||
|
return this.repositories.clients.get(requireOwnerScope(actor), id) ?? this.throwNotFound("Müşteri");
|
||||||
|
}
|
||||||
|
|
||||||
|
createClient(actor: DomainActor, input: unknown) {
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
const value = parseDomainInput(clientCreateSchema, input);
|
||||||
|
return this.repositories.clients.create(scope, { ...value, id: value.id ?? this.id() });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateClient(actor: DomainActor, id: string, input: unknown) {
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
const value = parseDomainInput(clientUpdateSchema, input);
|
||||||
|
return this.repositories.clients.update(scope, id, value) ?? this.throwNotFound("Müşteri");
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteClient(actor: DomainActor, id: string) {
|
||||||
|
return this.repositories.clients.remove(requireOwnerScope(actor), id) ?? this.throwNotFound("Müşteri");
|
||||||
|
}
|
||||||
|
|
||||||
|
addClientActivity(actor: DomainActor, input: unknown) {
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
const value = parseDomainInput(clientActivityCreateSchema, input);
|
||||||
|
this.requireOwnedClient(scope, value.clientId);
|
||||||
|
return this.repositories.clients.createActivity(scope, { ...value, id: value.id ?? this.id() });
|
||||||
|
}
|
||||||
|
|
||||||
|
listProjects(actor: DomainActor) {
|
||||||
|
if (actor.role === "client") {
|
||||||
|
return this.repositories.projects.listForClient(requireClientScope(actor));
|
||||||
|
}
|
||||||
|
return this.repositories.projects.list(requireOwnerScope(actor));
|
||||||
|
}
|
||||||
|
|
||||||
|
getProject(actor: DomainActor, id: string) {
|
||||||
|
const project = actor.role === "client"
|
||||||
|
? this.repositories.projects.getForClient(requireClientScope(actor), id)
|
||||||
|
: this.repositories.projects.get(requireOwnerScope(actor), id);
|
||||||
|
return project ?? this.throwNotFound("Proje");
|
||||||
|
}
|
||||||
|
|
||||||
|
createProject(actor: DomainActor, input: unknown) {
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
const value = parseDomainInput(projectCreateSchema, input);
|
||||||
|
this.assertProjectClient(scope, value.type, value.clientId);
|
||||||
|
return this.repositories.projects.create(scope, { ...value, id: value.id ?? this.id() });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProject(actor: DomainActor, projectId: string, input: unknown) {
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
const current = this.repositories.projects.get(scope, projectId) ?? this.throwNotFound("Proje");
|
||||||
|
const value = parseDomainInput(projectUpdateSchema, input);
|
||||||
|
this.assertProjectClient(scope, value.type ?? current.type, value.clientId === undefined ? current.clientId : value.clientId);
|
||||||
|
return this.repositories.projects.update(scope, projectId, value) ?? this.throwNotFound("Proje");
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteProject(actor: DomainActor, id: string) {
|
||||||
|
return this.repositories.projects.remove(requireOwnerScope(actor), id) ?? this.throwNotFound("Proje");
|
||||||
|
}
|
||||||
|
|
||||||
|
listTasks(actor: DomainActor, projectId?: string) {
|
||||||
|
if (actor.role === "client") {
|
||||||
|
const scope = requireClientScope(actor);
|
||||||
|
if (projectId) this.getProject(actor, projectId);
|
||||||
|
return this.repositories.tasks.listPublicForClient(scope, projectId);
|
||||||
|
}
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
const rows = this.repositories.tasks.list(scope);
|
||||||
|
return projectId ? rows.filter((task) => task.projectId === projectId) : rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
createTask(actor: DomainActor, input: unknown) {
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
const value = parseDomainInput(taskCreateSchema, input);
|
||||||
|
this.assertTaskRelations(scope, value);
|
||||||
|
const task = this.repositories.tasks.create(scope, { ...value, id: value.id ?? this.id() });
|
||||||
|
if (task.projectId) this.recalculateProjectProgress(scope, task.projectId);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTask(actor: DomainActor, taskId: string, input: unknown) {
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
const current = this.repositories.tasks.get(scope, taskId) ?? this.throwNotFound("Görev");
|
||||||
|
const value = parseDomainInput(taskUpdateSchema, input);
|
||||||
|
const merged = { ...current, ...value };
|
||||||
|
this.assertTaskRelations(scope, merged);
|
||||||
|
const task = this.repositories.tasks.update(scope, taskId, value) ?? this.throwNotFound("Görev");
|
||||||
|
if (current.projectId) this.recalculateProjectProgress(scope, current.projectId);
|
||||||
|
if (task.projectId && task.projectId !== current.projectId) this.recalculateProjectProgress(scope, task.projectId);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteTask(actor: DomainActor, taskId: string) {
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
const task = this.repositories.tasks.remove(scope, taskId) ?? this.throwNotFound("Görev");
|
||||||
|
if (task.projectId) this.recalculateProjectProgress(scope, task.projectId);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
createCalendarEvent(actor: DomainActor, input: unknown) {
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
const value = parseDomainInput(calendarEventCreateSchema, input);
|
||||||
|
this.assertTaskRelations(scope, value);
|
||||||
|
return this.repositories.calendar.create(scope, { ...value, id: value.id ?? this.id() });
|
||||||
|
}
|
||||||
|
|
||||||
|
listCalendarEvents(actor: DomainActor) {
|
||||||
|
return this.repositories.calendar.list(requireOwnerScope(actor));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCalendarEvent(actor: DomainActor, eventId: string, input: unknown) {
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
const current = this.repositories.calendar.get(scope, eventId) ?? this.throwNotFound("Takvim kaydı");
|
||||||
|
const value = parseDomainInput(calendarEventUpdateSchema, input);
|
||||||
|
const merged = { ...current, ...value };
|
||||||
|
if (merged.endsAt && merged.endsAt < merged.startsAt) {
|
||||||
|
throw new DomainError("VALIDATION_ERROR", "Bitiş zamanı başlangıç zamanından önce olamaz.");
|
||||||
|
}
|
||||||
|
this.assertTaskRelations(scope, merged);
|
||||||
|
return this.repositories.calendar.update(scope, eventId, value) ?? this.throwNotFound("Takvim kaydı");
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCalendarEvent(actor: DomainActor, eventId: string) {
|
||||||
|
return this.repositories.calendar.remove(requireOwnerScope(actor), eventId) ?? this.throwNotFound("Takvim kaydı");
|
||||||
|
}
|
||||||
|
|
||||||
|
createFinanceTransaction(actor: DomainActor, input: unknown) {
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
const value = parseDomainInput(financeTransactionCreateSchema, input);
|
||||||
|
this.assertTaskRelations(scope, value);
|
||||||
|
return this.repositories.finance.create(scope, { ...value, id: value.id ?? this.id() });
|
||||||
|
}
|
||||||
|
|
||||||
|
listFinanceTransactions(actor: DomainActor) {
|
||||||
|
return this.repositories.finance.list(requireOwnerScope(actor));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFinanceTransaction(actor: DomainActor, transactionId: string, input: unknown) {
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
const current = this.repositories.finance.get(scope, transactionId) ?? this.throwNotFound("Finans kaydı");
|
||||||
|
const value = parseDomainInput(financeTransactionUpdateSchema, input);
|
||||||
|
this.assertTaskRelations(scope, { ...current, ...value });
|
||||||
|
return this.repositories.finance.update(scope, transactionId, value) ?? this.throwNotFound("Finans kaydı");
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteFinanceTransaction(actor: DomainActor, transactionId: string) {
|
||||||
|
return this.repositories.finance.remove(requireOwnerScope(actor), transactionId) ?? this.throwNotFound("Finans kaydı");
|
||||||
|
}
|
||||||
|
|
||||||
|
saveJournalEntry(actor: DomainActor, input: unknown) {
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
const value = parseDomainInput(journalEntrySchema, input);
|
||||||
|
const existing = this.repositories.journal.getByDate(scope, value.entryDate);
|
||||||
|
if (existing) return this.repositories.journal.updateByDate(scope, value.entryDate, value);
|
||||||
|
return this.repositories.journal.create(scope, { ...value, id: value.id ?? this.id() });
|
||||||
|
}
|
||||||
|
|
||||||
|
listJournalEntries(actor: DomainActor) {
|
||||||
|
return this.repositories.journal.list(requireOwnerScope(actor));
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteJournalEntry(actor: DomainActor, entryId: string) {
|
||||||
|
return this.repositories.journal.remove(requireOwnerScope(actor), entryId) ?? this.throwNotFound("Günlük kaydı");
|
||||||
|
}
|
||||||
|
|
||||||
|
addPlanningSection(actor: DomainActor, input: unknown) {
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
const value = parseDomainInput(planningSectionCreateSchema, input);
|
||||||
|
this.requireOwnedProject(scope, value.projectId);
|
||||||
|
return this.repositories.planning.create(scope, { ...value, id: value.id ?? this.id() });
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePlanningSection(actor: DomainActor, sectionId: string, input: unknown) {
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
if (!this.repositories.planning.get(scope, sectionId)) throw notFound("Planlama bölümü");
|
||||||
|
const value = parseDomainInput(planningSectionUpdateSchema, input);
|
||||||
|
return this.repositories.planning.update(scope, sectionId, value) ?? this.throwNotFound("Planlama bölümü");
|
||||||
|
}
|
||||||
|
|
||||||
|
deletePlanningSection(actor: DomainActor, sectionId: string) {
|
||||||
|
return this.repositories.planning.remove(requireOwnerScope(actor), sectionId) ?? this.throwNotFound("Planlama bölümü");
|
||||||
|
}
|
||||||
|
|
||||||
|
listPlanningSections(actor: DomainActor, projectId: string) {
|
||||||
|
if (actor.role === "client") {
|
||||||
|
const scope = requireClientScope(actor);
|
||||||
|
this.getProject(actor, projectId);
|
||||||
|
return this.repositories.planning.listForClient(scope, projectId);
|
||||||
|
}
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
this.requireOwnedProject(scope, projectId);
|
||||||
|
return this.repositories.planning.list(scope, projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestRevision(actor: DomainActor, input: unknown) {
|
||||||
|
const scope = requireClientScope(actor);
|
||||||
|
const value = parseDomainInput(revisionCreateSchema, input);
|
||||||
|
const revisionId = value.id ?? this.id();
|
||||||
|
|
||||||
|
return this.db.transaction((tx) => {
|
||||||
|
const project = tx.select().from(projects).where(and(eq(projects.id, value.projectId), eq(projects.clientId, scope.clientId))).get();
|
||||||
|
if (!project) throw notFound("Proje");
|
||||||
|
if (project.status !== "active") {
|
||||||
|
throw new DomainError("INVARIANT_VIOLATION", "Yalnızca aktif projeler revizyon kabul eder.");
|
||||||
|
}
|
||||||
|
const used = tx.select({ value: count() }).from(projectRevisions).where(and(eq(projectRevisions.projectId, project.id), eq(projectRevisions.clientId, scope.clientId), ne(projectRevisions.status, "rejected"))).get()?.value ?? 0;
|
||||||
|
if (used >= project.revisionQuota) throw conflict("Projenin revizyon kotası doldu.");
|
||||||
|
return tx.insert(projectRevisions).values({
|
||||||
|
id: revisionId,
|
||||||
|
ownerUserId: project.ownerUserId,
|
||||||
|
projectId: project.id,
|
||||||
|
clientId: scope.clientId,
|
||||||
|
requestedByUserId: scope.authUserId,
|
||||||
|
description: value.description,
|
||||||
|
}).returning().get();
|
||||||
|
}, { behavior: "immediate" });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRevisionStatus(actor: DomainActor, revisionId: string, statusInput: unknown) {
|
||||||
|
const status = parseDomainInput(revisionStatusSchema, statusInput);
|
||||||
|
return this.repositories.revisions.updateStatus(requireOwnerScope(actor), revisionId, status) ?? this.throwNotFound("Revizyon");
|
||||||
|
}
|
||||||
|
|
||||||
|
listRevisions(actor: DomainActor, projectId: string) {
|
||||||
|
if (actor.role === "client") {
|
||||||
|
const scope = requireClientScope(actor);
|
||||||
|
this.getProject(actor, projectId);
|
||||||
|
return this.repositories.revisions.listForClient(scope, projectId);
|
||||||
|
}
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
this.requireOwnedProject(scope, projectId);
|
||||||
|
return this.repositories.revisions.list(scope, projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
createChatSession(actor: DomainActor, input: unknown) {
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
const value = parseDomainInput(chatSessionCreateSchema, input);
|
||||||
|
return this.repositories.chat.createSession(scope, { ...value, id: value.id ?? this.id() });
|
||||||
|
}
|
||||||
|
|
||||||
|
addChatMessage(actor: DomainActor, input: unknown) {
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
const value = parseDomainInput(chatMessageCreateSchema, input);
|
||||||
|
if (!this.repositories.chat.getSession(scope, value.sessionId)) throw notFound("Sohbet");
|
||||||
|
if (value.contextJournalEntryIds.length > 0) {
|
||||||
|
const accessible = this.db.select({ value: count() }).from(journalEntries).where(and(eq(journalEntries.ownerUserId, scope.ownerUserId), inArray(journalEntries.id, value.contextJournalEntryIds))).get()?.value ?? 0;
|
||||||
|
if (accessible !== new Set(value.contextJournalEntryIds).size) throw notFound("Günlük kaydı");
|
||||||
|
}
|
||||||
|
const message = this.repositories.chat.createMessage({ ...value, id: value.id ?? this.id() });
|
||||||
|
this.db.update(chatSessions).set({ updatedAt: new Date() }).where(and(eq(chatSessions.id, value.sessionId), eq(chatSessions.ownerUserId, scope.ownerUserId))).run();
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAnalytics(actor: DomainActor) {
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
const finance = this.repositories.analytics.summary(scope) ?? { incomeMinor: 0, expenseMinor: 0, plannedMinor: 0 };
|
||||||
|
return {
|
||||||
|
finance: { ...finance, netMinor: finance.incomeMinor - finance.expenseMinor },
|
||||||
|
projectsByStatus: this.repositories.analytics.projectStatusCounts(scope),
|
||||||
|
tasksByStatus: this.repositories.analytics.taskStatusCounts(scope),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
createProposal(actor: DomainActor, input: unknown) {
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
const value = parseDomainInput(proposalCreateSchema, input);
|
||||||
|
this.assertTaskRelations(scope, value);
|
||||||
|
return this.repositories.business.createProposal(scope, { ...value, id: value.id ?? this.id() });
|
||||||
|
}
|
||||||
|
|
||||||
|
createContract(actor: DomainActor, input: unknown) {
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
const value = parseDomainInput(contractCreateSchema, input);
|
||||||
|
if (value.clientId) this.requireOwnedClient(scope, value.clientId);
|
||||||
|
if (value.proposalId && !this.repositories.business.getProposal(scope, value.proposalId)) {
|
||||||
|
throw notFound("Teklif");
|
||||||
|
}
|
||||||
|
return this.repositories.business.createContract(scope, { ...value, id: value.id ?? this.id() });
|
||||||
|
}
|
||||||
|
|
||||||
|
createInvoice(actor: DomainActor, input: unknown) {
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
const value = parseDomainInput(invoiceCreateSchema, input);
|
||||||
|
this.assertTaskRelations(scope, value);
|
||||||
|
return this.repositories.business.createInvoice(scope, { ...value, id: value.id ?? this.id() });
|
||||||
|
}
|
||||||
|
|
||||||
|
createSubscription(actor: DomainActor, input: unknown) {
|
||||||
|
const scope = requireOwnerScope(actor);
|
||||||
|
const value = parseDomainInput(subscriptionCreateSchema, input);
|
||||||
|
return this.repositories.business.createSubscription(scope, { ...value, id: value.id ?? this.id() });
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireOwnedClient(scope: OwnerScope, clientId: string) {
|
||||||
|
return this.repositories.clients.get(scope, clientId) ?? this.throwNotFound("Müşteri");
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireOwnedProject(scope: OwnerScope, projectId: string) {
|
||||||
|
return this.repositories.projects.get(scope, projectId) ?? this.throwNotFound("Proje");
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertProjectClient(scope: OwnerScope, type: string, clientId: string | null | undefined) {
|
||||||
|
if (type === "side_project" && clientId) {
|
||||||
|
throw new DomainError("INVARIANT_VIOLATION", "Yan projeler bir müşteriye bağlanamaz.");
|
||||||
|
}
|
||||||
|
if (clientId) this.requireOwnedClient(scope, clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertTaskRelations(scope: OwnerScope, value: {
|
||||||
|
clientId?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
taskId?: string | null;
|
||||||
|
sourceJournalEntryId?: string | null;
|
||||||
|
}) {
|
||||||
|
const client = value.clientId ? this.requireOwnedClient(scope, value.clientId) : null;
|
||||||
|
const project = value.projectId ? this.requireOwnedProject(scope, value.projectId) : null;
|
||||||
|
if (project?.clientId && client?.id && project.clientId !== client.id) {
|
||||||
|
throw new DomainError("INVARIANT_VIOLATION", "Proje ve müşteri ilişkisi uyuşmuyor.");
|
||||||
|
}
|
||||||
|
if (project?.clientId && !client) {
|
||||||
|
throw new DomainError("INVARIANT_VIOLATION", "Müşteri projesine bağlı kayıt müşteri kimliğini içermelidir.");
|
||||||
|
}
|
||||||
|
if (value.taskId) {
|
||||||
|
const task = this.repositories.tasks.get(scope, value.taskId) ?? this.throwNotFound("Görev");
|
||||||
|
if (value.projectId && task.projectId && value.projectId !== task.projectId) {
|
||||||
|
throw new DomainError("INVARIANT_VIOLATION", "Etkinlik ve görev proje ilişkisi uyuşmuyor.");
|
||||||
|
}
|
||||||
|
if (value.clientId && task.clientId && value.clientId !== task.clientId) {
|
||||||
|
throw new DomainError("INVARIANT_VIOLATION", "Etkinlik ve görev müşteri ilişkisi uyuşmuyor.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.sourceJournalEntryId && !this.repositories.journal.get(scope, value.sourceJournalEntryId)) {
|
||||||
|
throw notFound("Günlük kaydı");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private recalculateProjectProgress(scope: OwnerScope, projectId: string) {
|
||||||
|
const project = this.repositories.projects.get(scope, projectId);
|
||||||
|
if (!project || project.progressType !== "auto") return;
|
||||||
|
const counts = this.repositories.tasks.progressCounts(scope, projectId);
|
||||||
|
const progress = counts?.total ? Math.round((Number(counts.done) / counts.total) * 100) : 0;
|
||||||
|
this.repositories.projects.update(scope, projectId, { progress });
|
||||||
|
}
|
||||||
|
|
||||||
|
private throwNotFound(resource: string): never {
|
||||||
|
throw notFound(resource);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { getSqliteConnection } from "../db/client";
|
||||||
|
import { DomainService } from "./domain";
|
||||||
|
|
||||||
|
export function getDomainService(): DomainService {
|
||||||
|
return new DomainService(getSqliteConnection().db);
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user