diff --git a/.env.example b/.env.example index 12a2522..aed0096 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,6 @@ DATABASE_PATH= OLLAMA_BASE_URL=http://127.0.0.1:11434/v1 AI_REQUEST_TIMEOUT_MS=30000 + +# Optional SemVer floor advertised to future iOS/Android clients. Empty disables enforcement. +NETA_MINIMUM_MOBILE_VERSION= diff --git a/README.md b/README.md index fd8183d..736f680 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ Opsiyonel alanlar: - `DATABASE_PATH`: Varsayılan `DATA_DIR/neta.db` yerine özel SQLite yolu. - `OLLAMA_BASE_URL`: Varsayılan `http://127.0.0.1:11434/v1`. - `AI_REQUEST_TIMEOUT_MS`: AI istek timeout'u; varsayılan `30000`. +- `NETA_MINIMUM_MOBILE_VERSION`: Mobil istemcilere ilan edilen opsiyonel SemVer alt sınırı. AI provider API key'leri environment'a yazılmaz; owner ayarından girilir, server-side şifreli saklanır ve browser'a geri dönmez. @@ -160,12 +161,28 @@ pnpm db:import:supabase -- \ Raporu doğruladıktan ve backup aldıktan sonra aynı komutu `--dry-run` olmadan çalıştırın. Bundle formatı, normalization kararları, dosya yapısı ve production cutover/rollback adımları [Faz 8 rehberinde](docs/self-hosted-redesign/phase-8-import-release.md) tanımlıdır. +## Mobil istemci ve instance discovery + +React Native istemcileri bir Neta kurulumunu şu public endpoint'lerle tanıyabilir: + +```text +GET /.well-known/neta +GET /api/v1/meta +GET /api/v1/health +GET /api/v1/me +``` + +`/.well-known/neta` kalıcı instance kimliğini ve API URL'sini, `/api/v1/meta` marka/sürüm/capability sözleşmesini döndürür. `/api/v1/me` Better Auth session gerektirir ve token veya secret döndürmez. + +Device pairing henüz runtime'a açılmamıştır; capability `planned` durumundadır. Mobil bağlantı algoritması ve API version kuralları [Faz 9 rehberinde](docs/self-hosted-redesign/phase-9-mobile-api.md), gelecek pairing/token güvenliği [ADR-0018](docs/self-hosted-redesign/adr-0018-device-pairing.md) belgesinde tanımlıdır. + ## Kalite kontrolleri ```bash pnpm typecheck pnpm phase8:release-boundary pnpm phase8:import-smoke +pnpm phase9:smoke pnpm build ``` diff --git a/app/.well-known/neta/route.ts b/app/.well-known/neta/route.ts new file mode 100644 index 0000000..716f6ef --- /dev/null +++ b/app/.well-known/neta/route.ts @@ -0,0 +1,34 @@ +import { getNetaDiscoveryDocument } from "@/server/api/v1/runtime"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export function GET() { + try { + return Response.json(getNetaDiscoveryDocument(), { + headers: { + "Cache-Control": "public, max-age=60, stale-while-revalidate=300", + "X-Content-Type-Options": "nosniff", + }, + }); + } catch (error) { + console.error("Neta discovery failed", error); + return Response.json( + { + protocol: "neta", + discoveryVersion: 1, + error: { + code: "SERVICE_UNAVAILABLE", + message: "Instance keşif bilgisi geçici olarak kullanılamıyor.", + }, + }, + { + status: 503, + headers: { + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }, + }, + ); + } +} diff --git a/app/api/v1/health/route.ts b/app/api/v1/health/route.ts new file mode 100644 index 0000000..1da39ec --- /dev/null +++ b/app/api/v1/health/route.ts @@ -0,0 +1,27 @@ +import { apiV1Error, apiV1Success } from "@/server/api/v1/responses"; +import { checkReadiness } from "@/server/db/health"; +import { DomainError } from "@/server/domain/errors"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export function GET() { + const readiness = checkReadiness(); + const checkedAt = new Date().toISOString(); + + if (!readiness.ok) { + return apiV1Error( + new DomainError( + "SERVICE_UNAVAILABLE", + "Instance henüz isteklere hazır değil.", + { checks: readiness.checks, checkedAt }, + ), + ); + } + + return apiV1Success({ + status: "ok", + checks: readiness.checks, + checkedAt, + }); +} diff --git a/app/api/v1/me/route.ts b/app/api/v1/me/route.ts new file mode 100644 index 0000000..ac5619d --- /dev/null +++ b/app/api/v1/me/route.ts @@ -0,0 +1,36 @@ +import { apiV1Error, apiV1Success } from "@/server/api/v1/responses"; +import { getSessionContextFromHeaders } from "@/server/auth/session"; +import { getServerConfig } from "@/server/config"; +import { DomainError } from "@/server/domain/errors"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + try { + const context = await getSessionContextFromHeaders(new Headers(request.headers)); + if (!context) { + throw new DomainError("UNAUTHENTICATED", "Geçerli bir oturum gerekli."); + } + + return apiV1Success({ + user: { + id: context.user.id, + email: context.profile.email, + displayName: context.profile.displayName, + role: context.profile.role, + clientId: context.profile.clientId, + imageUrl: absoluteOptionalUrl(context.user.image), + }, + session: { + expiresAt: context.session.expiresAt.toISOString(), + }, + }); + } catch (error) { + return apiV1Error(error); + } +} + +function absoluteOptionalUrl(value: string | null | undefined): string | null { + return value ? new URL(value, `${getServerConfig().appUrl}/`).toString() : null; +} diff --git a/app/api/v1/meta/route.ts b/app/api/v1/meta/route.ts new file mode 100644 index 0000000..2b2eee8 --- /dev/null +++ b/app/api/v1/meta/route.ts @@ -0,0 +1,17 @@ +import { apiV1Error, apiV1Success } from "@/server/api/v1/responses"; +import { getNetaInstanceMetadata } from "@/server/api/v1/runtime"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export function GET() { + try { + return apiV1Success(getNetaInstanceMetadata(), { + headers: { + "Cache-Control": "public, max-age=60, stale-while-revalidate=300", + }, + }); + } catch (error) { + return apiV1Error(error); + } +} diff --git a/docs/self-hosted-redesign/adr-0018-device-pairing.md b/docs/self-hosted-redesign/adr-0018-device-pairing.md new file mode 100644 index 0000000..706271d --- /dev/null +++ b/docs/self-hosted-redesign/adr-0018-device-pairing.md @@ -0,0 +1,163 @@ +--- +title: ADR-0018 — Mobil Device Pairing ve Token Lifecycle +status: accepted-design-not-implemented +date: 2026-07-17 +--- + +# ADR-0018 — Mobil device pairing ve token lifecycle + +## Bağlam + +React Native istemcisi kullanıcı tarafından girilen self-hosted Neta URL'sine bağlanacak. Web session cookie'sini kopyalamak, uzun ömürlü API key vermek veya owner şifresini cihazda sürekli saklamak güvenli bir pairing modeli değildir. + +Faz 9 yalnızca discovery ve API v1 temelini yayınlar. Pairing/token endpoint'leri bu ADR uygulanmadan açılmaz. + +## Karar + +İlk device pairing sürümü owner cihazları için tek kullanımlık, kısa ömürlü bir pairing challenge ve DB-backed opaque token modeli kullanacaktır. + +Planlanan endpoint'ler: + +```text +POST /api/v1/pairing-codes +POST /api/v1/device-sessions/exchange +POST /api/v1/device-sessions/refresh +GET /api/v1/device-sessions +DELETE /api/v1/device-sessions/:id +``` + +### Pairing oluşturma + +- Yalnızca aktif freelancer session'ı pairing oluşturabilir. +- Browser owner'dan güncel şifre veya eşdeğer step-up doğrulaması istenir. +- QR modu 256-bit rastgele secret taşır. +- Manuel giriş modu 10 karakter Crockford Base32 kod kullanır; benzer karakterler kullanılmaz. +- DB'de yalnızca HMAC/SHA-256 digest saklanır; raw secret yalnızca bir kez gösterilir. +- Challenge en fazla 5 dakika geçerlidir ve tek kullanımlıdır. +- Challenge; creator, expiry, attempt count, requested scopes ve durum içerir. +- Aynı owner için en fazla üç aktif challenge bulunabilir. +- Beş başarısız deneme challenge'ı kilitler. +- Oluşturma ve exchange IP/instance seviyesinde rate limit ve auth audit event üretir. + +### Exchange + +Mobil istemci şu bilgileri gönderir: + +- Pairing secret/code +- Cihazda üretilmiş opaque install ID +- Kullanıcının verdiği device name +- Platform (`ios`/`android`) +- App version +- OS version'ın hassas olmayan major bilgisi + +Server challenge'ı `BEGIN IMMEDIATE` transaction içinde doğrular ve tüketir. Aynı transaction device session/token family kaydını oluşturur. Başarısız exchange challenge'ı tüketmez; attempt sayısını artırır. + +Owner role/scopeları server tarafından atanır. İstemci owner/user ID veya scope seçemez. + +### Token modeli + +- Tokenlar JWT değil, 256-bit opaque random bearer değerleridir. +- DB'de yalnızca keyed digest saklanır. +- Access token varsayılan 15 dakika geçerlidir. +- Refresh token varsayılan 30 gün geçerlidir. +- Her refresh işleminde access ve refresh token birlikte rotate edilir. +- Eski refresh token yeniden kullanılırsa token family `compromised` olur ve family içindeki tüm tokenlar atomik revoke edilir. +- Aynı cihaz için eşzamanlı refresh yarışı kısa grace/replay kaydıyla açıkça yönetilir; iki aktif refresh token bırakılmaz. +- Bearer token yalnızca `Authorization: Bearer` header'ında kabul edilir; query, URL veya log'a yazılmaz. +- Raw tokenlar API response dışında hiçbir log/audit kaydına girmez. +- React Native tokenları iOS Keychain/Android Keystore destekli secure storage'da tutar. +- İlk sürüm bearer modelidir. Device-bound public key/DPoP ayrı ADR olmadan eklenmez. + +### Scope + +İlk pairing yalnızca freelancer cihazı içindir. Token scope'ları explicit allowlist'tir: + +```text +profile:read +clients:read +projects:read +tasks:read +calendar:read +finance:read +journal:read +``` + +Mutation scope'ları ilgili `/api/v1` resource endpoint'leri ve authorization testleri yayınlandıkça ayrı ayrı eklenir. `*` veya implicit admin scope kullanılmaz. + +Client portal pairing'i owner pairing'inden ayrı ürün/güvenlik kararıdır; ilk implementasyona dahil değildir. + +## Device token lifecycle + +Durumlar: + +```text +pending_pairing -> active -> expired + -> revoked + -> compromised +``` + +- `pending_pairing`: Challenge üretildi, token yok. +- `active`: Exchange tamamlandı ve token family kullanılabilir. +- `expired`: Refresh lifetime sona erdi. +- `revoked`: Owner, kullanıcı disable, şifre güvenlik olayı veya “tüm cihazlardan çıkış” nedeniyle kapatıldı. +- `compromised`: Refresh reuse veya güvenlik sinyali tespit edildi. + +Kurallar: + +- Owner dashboard'u cihaz adı, platform, oluşturulma, son kullanım ve yaklaşık IP bilgisini görür. +- Owner tek cihazı veya tüm cihazları revoke edebilir. +- Client/freelancer hesabı disable edildiğinde tüm device session'lar transaction içinde revoke edilir. +- Owner şifresi değiştiğinde varsayılan politika tüm device session'ları revoke etmektir. +- 30 gün kullanılmayan device session expire edilir. +- Son kullanım zamanı en fazla beş dakikada bir coalesce edilerek yazılır. +- Token cleanup job'u uygulama başlangıcında ve kontrollü periyotta expired kayıtları temizler; aktif request path'i toplu cleanup yapmaz. + +## Backup ve restore güvenliği + +Eski DB backup'ı revoke edilmiş token kayıtlarını yeniden aktif hale getirebilir. Pairing implementasyonunun release blocker'ı: + +1. `instance_settings` içinde bir device token epoch tutulur. +2. Bütün token digest doğrulamaları bu epoch'a bağlanır. +3. `db:restore` başarılı atomik swap sonrasında epoch'u yeni random değerle rotate eder. +4. Böylece restore tüm eski device tokenları otomatik geçersiz kılar. +5. Owner restore sonrasında cihazları yeniden pair eder. + +Bu mekanizma uygulanmadan device token endpoint'leri yayınlanamaz. + +## HTTPS ve transport + +- Remote pairing, exchange, refresh ve authenticated API için HTTPS zorunludur. +- Reverse proxy `X-Forwarded-Proto`/canonical origin'i doğru iletmelidir. +- HTTP yalnızca loopback/emulator geliştirme ortamında kabul edilir ve production token üretmez. +- TLS sertifika hatası kullanıcı tarafından sessizce bypass edilemez. +- Discovery API origin değiştirirse mobil istemci kullanıcı onayı olmadan credential göndermez. + +## Audit ve privacy + +Audit event'leri: + +- `pairing_created` +- `pairing_failed` +- `pairing_consumed` +- `device_session_refreshed` +- `device_session_revoked` +- `device_token_reuse_detected` + +Audit metadata raw token, pairing secret, tam IP geçmişi veya gereksiz device fingerprint içermez. Device name kullanıcı tarafından değiştirilebilir ve output-encode edilir. + +## Uygulama öncesi zorunlu testler + +- Pairing raw secret'ın DB/log'da bulunmaması +- Expired, consumed, locked ve brute-force challenge negatifleri +- Concurrent double exchange'de yalnızca bir başarı +- Owner/client role ve cross-owner negatifleri +- Access expiry ve refresh rotation +- Refresh reuse ile family revoke +- Disabled user ve password change revoke +- Restore sonrası token epoch invalidation +- HTTPS/loopback policy +- Tokenların URL, error ve audit output'una sızmaması + +## Sonuç + +Faz 9'da capability `auth.device-pairing` değeri `planned` kalır. Bu ADR'ın schema, service, rate-limit, restore epoch ve negatif test maddeleri tamamlanmadan `pairing-codes` veya `device-sessions` route'u oluşturulmaz. diff --git a/docs/self-hosted-redesign/neta-self-hosted-v3-master-plan.md b/docs/self-hosted-redesign/neta-self-hosted-v3-master-plan.md index 1e023a0..7d84fef 100644 --- a/docs/self-hosted-redesign/neta-self-hosted-v3-master-plan.md +++ b/docs/self-hosted-redesign/neta-self-hosted-v3-master-plan.md @@ -2,7 +2,7 @@ title: Neta Self-Hosted v3 Ana Dönüşüm Planı description: Supabase çıkışı, SQLite tabanlı backend, Better Auth, Poyraz UI v3 ve instance özelleştirmesi için ana yol haritası. status: active -current_phase: "9 — Mobil API hazırlığı" +current_phase: "10 — Kullanıcı yönlendirmeli sayfa tasarımları" last_updated: 2026-07-17 --- @@ -527,7 +527,7 @@ Faz 5'in başlangıç noktası freelancer runtime'ındaki Supabase erişimleridi - [x] Portal backend'i Faz 6 sözleşmesine göre tamamlandı. - [x] AI/chat ve business backend'i Faz 7 sözleşmesine göre tamamlandı. - [x] Import ve runtime Supabase temizliği Faz 8'de tamamlandı. -- [ ] Mobil API sınırı Faz 9'da tamamlandı. +- [x] Mobil API sınırı Faz 9'da tamamlandı. ## 15. Revizyon güvenliği ve kota işlemi @@ -580,13 +580,13 @@ Mobil hazırlık checklist'i: - [x] API response envelope standardı tanımlandı. - [x] API hata kodları tanımlandı. -- [ ] API sürümleme stratejisi tanımlandı. -- [ ] Instance metadata sözleşmesi tanımlandı. -- [ ] Minimum desteklenen client sürümü alanı düşünüldü. -- [ ] Capability listesi sözleşmesi düşünüldü. +- [x] API sürümleme stratejisi tanımlandı. +- [x] Instance metadata sözleşmesi tanımlandı. +- [x] Minimum desteklenen client sürümü alanı düşünüldü. +- [x] Capability listesi sözleşmesi düşünüldü. - [x] Service katmanı cookie/Next.js objelerine bağımlı değil. -- [ ] Mobil pairing ilk release kapsamı dışında tutuldu. -- [ ] Gelecekte HTTPS zorunluluğu belgelendi. +- [x] Mobil pairing ilk release kapsamı dışında tutuldu. +- [x] Gelecekte HTTPS zorunluluğu belgelendi. ## 17. Supabase veri import ve cutover planı @@ -744,7 +744,7 @@ Mümkün olduğunda küçük ve doğrudan test araçları tercih edilir; test al - [x] Branding ayarları belgelendi. - [x] Backup/restore belgelendi. - [x] Upgrade/migration akışı belgelendi. -- [ ] Mobil API sınırı belgelendi. +- [x] Mobil API sınırı belgelendi. - [x] Eski ve çelişkili Supabase belgeleri archive veya kaldırıldı. - [x] ADR-0006 Poyraz UI v3 kararıyla güncellendi. - [x] ADR-0007 ile PWA runtime durumu uyumlu hale getirildi. @@ -984,11 +984,24 @@ Faz 8 tamamlanma notu (2026-07-17): Amaç: React Native geliştirmesine başlamadan önce instance keşif ve stabil API sınırını tamamlamak. -- [ ] `/api/v1` sözleşmesi yayınlandı. -- [ ] `/.well-known/neta` sözleşmesi yayınlandı. -- [ ] Instance metadata ve capability modeli tamamlandı. -- [ ] Pairing güvenlik tasarımı ayrı ADR olarak yazıldı. -- [ ] Device token lifecycle tasarlandı. +- [x] `/api/v1` sözleşmesi yayınlandı. +- [x] `/.well-known/neta` sözleşmesi yayınlandı. +- [x] Instance metadata ve capability modeli tamamlandı. +- [x] Pairing güvenlik tasarımı ayrı ADR olarak yazıldı. +- [x] Device token lifecycle tasarlandı. + +Faz 9 tamamlanma notu (2026-07-17): + +- `/.well-known/neta`; protocol/discovery sürümü, kalıcı instance kimliği, application adı, mutlak API/meta/health URL'leri ve HTTPS politikasını public discovery belgesi olarak yayınlar. +- `/api/v1/meta`; server/API sürümü, instance/organization kimliği, absolute branding asset URL'leri, SemVer minimum mobil sürümü, platformlar, auth yöntemi, capability durumları ve navigasyon linklerini standart success envelope içinde döndürür. +- `/api/v1/health` SQLite/migration readiness'i v1 envelope ve `SERVICE_UNAVAILABLE` hata koduyla sunar. `/api/v1/me` yalnızca geçerli Better Auth session'ıyla güvenli user/role/client bağı ve session expiry döndürür; token/password/secret içermez. +- `instance_settings` ve migration `0007`; ilk discovery isteğinde concurrency-safe oluşturulan UUID'yi backup/restore ile korunacak kalıcı instance kimliği yapar. Domain veya marka adı kimlik olarak kullanılmaz. +- API major sürümü URL'de `/api/v1` olarak kilitlendi. Additive alan/capability değişiklikleri v1 içinde; alan silme, tip/anlam/auth kırılması yeni major içinde yapılacaktır. Tüm v1 yanıtları `X-Neta-API-Version: 1` taşır. +- `NETA_MINIMUM_MOBILE_VERSION` opsiyonel SemVer alt sınırı olarak eklendi. Bilinmeyen capability/alanları yok sayma ve `planned` capability'yi kullanmama kuralı belgelendi. +- Pairing runtime'a sahte/eksik endpoint olarak eklenmedi; `auth.device-pairing` capability'si `planned` durumundadır. Ayrı ADR-0018; tek kullanımlık challenge, rate limit, hash-only secret, opaque access/refresh token rotation, reuse detection, scope, revoke/expire/compromised lifecycle, secure storage ve restore sonrası token epoch invalidation gereksinimlerini kilitler. +- Mobil URL bağlantı algoritması; origin normalizasyonu, HTTPS, redirect/downgrade koruması, discovery/meta instance ID eşlemesi ve kimlik değişiminde credential'ı sessizce kullanmama kurallarıyla `phase-9-mobile-api.md` belgesinde yayınlandı. +- `phase9:api-boundary` pairing route'larının tasarım uygulanmadan açılmadığını ve API/service sınırını doğrular. `phase9:smoke`; eşzamanlı discovery, metadata, minimum sürüm, capabilities, health, anonymous/owner/client `/me`, disabled client reddi ve absolute branding URL akışlarını gerçek Next.js + Better Auth üzerinde doğrular. +- Typecheck, hedefli ESLint, migration drift kontrolü, production build, Faz 8 source/build artifact sınırı ve `git diff --check` başarılıdır. Çıkış kriteri: Mobil istemci backend'in iç uygulama detaylarına bağımlı olmadan entegrasyona başlayabilir. @@ -1002,8 +1015,8 @@ Başlangıç koşulları: - [x] Faz 6 portal backend geçişi tamamlandı. - [x] Faz 7 kapsamındaki runtime modülleri tamamlandı veya açıkça ertelendi. - [x] Faz 8 Supabase runtime temizliği ve release hardening tamamlandı. -- [ ] Faz 9 mobil API hazırlığı tamamlandı. -- [ ] Tasarım sırasında kullanılacak backend veri sözleşmeleri stabil. +- [x] Faz 9 mobil API hazırlığı tamamlandı. +- [x] Tasarım sırasında kullanılacak backend veri sözleşmeleri stabil. Sayfa grupları: @@ -1051,7 +1064,7 @@ Her sayfa için tasarım kabul checklist'i: - [x] Hedef schema tamamlandı. - [x] Service/repository sınırı tamamlandı. - [x] Server-side authorization tamamlandı. -- [ ] API v1 sınırı hazırlandı. +- [x] API v1 sınırı hazırlandı. ### Supabase çıkışı diff --git a/docs/self-hosted-redesign/phase-9-mobile-api.md b/docs/self-hosted-redesign/phase-9-mobile-api.md new file mode 100644 index 0000000..7fdc801 --- /dev/null +++ b/docs/self-hosted-redesign/phase-9-mobile-api.md @@ -0,0 +1,196 @@ +--- +title: Faz 9 — Mobil API ve Instance Discovery Sözleşmesi +description: React Native istemcileri için discovery, API v1, metadata, capability, sürümleme ve güvenlik sınırı. +status: completed +last_updated: 2026-07-17 +--- + +# Faz 9 — Mobil API ve instance discovery + +Bu faz mobil uygulamanın Neta backend iç yapısını bilmeden bir self-hosted instance'ı tanımasını sağlar. Mobil uygulamanın kendisi ve device pairing implementasyonu kapsam dışıdır. + +## 1. Bağlantı akışı + +Kullanıcı mobil uygulamaya instance URL'sini girer: + +```text +https://neta.example.com +``` + +İstemci: + +1. URL'yi yalnızca origin olacak şekilde normalize eder; credential, query ve fragment kabul etmez. +2. Production'da HTTPS olmayan remote origin'i reddeder. HTTP yalnızca `localhost`, `127.0.0.1` ve emulator geliştirme adresleri için açık kullanıcı onayıyla kullanılabilir. +3. `GET /.well-known/neta` çağrısını yapar. +4. `protocol=neta` ve desteklenen `discoveryVersion` değerini doğrular. +5. Discovery belgesindeki API URL'lerinin beklenen origin'den çıkmadığını doğrular. +6. `GET /api/v1/meta` çağrısını yapar ve `instance.id` değerini discovery `instanceId` değeriyle karşılaştırır. +7. Daha önce kaydedilmiş aynı origin farklı bir `instanceId` döndürürse bunu yeni/restore edilmiş instance olarak kullanıcıya açıkça gösterir; mevcut credential'ı sessizce yeniden kullanmaz. +8. Minimum client sürümünü ve capability listesini değerlendirir. +9. Instance kaydını `origin + instanceId + applicationName + iconUrl` ile yerel güvenli metadata alanına kaydeder. + +Redirect takibi en fazla üç hop olmalı ve HTTPS'ten HTTP'ye downgrade edilmemelidir. Discovery veya meta içindeki URL kullanıcı girdisinden bağımsız güven kaynağı sayılmaz. + +## 2. Endpoint özeti + +| Endpoint | Auth | Cache | Amaç | +| --- | --- | --- | --- | +| `GET /.well-known/neta` | Public | 60 saniye public | Instance ve API keşfi | +| `GET /api/v1/meta` | Public | 60 saniye public | Marka, sürüm ve capability bilgisi | +| `GET /api/v1/health` | Public | No-store | DB/migration readiness | +| `GET /api/v1/me` | Better Auth session | Private/no-store | Güvenli kullanıcı/session özeti | + +Public endpoint'ler session oluşturmaz ve secret dönmez. `/me` geçersiz, süresi dolmuş veya disabled hesaba ait session için `401 UNAUTHENTICATED` döndürür. + +## 3. Discovery v1 + +Örnek: + +```json +{ + "protocol": "neta", + "discoveryVersion": 1, + "instanceId": "6d26e558-9c93-4cd8-93a1-5c8d5166045a", + "applicationName": "Poyraz Studio", + "api": { + "version": "1", + "baseUrl": "https://neta.example.com/api/v1", + "metaUrl": "https://neta.example.com/api/v1/meta", + "healthUrl": "https://neta.example.com/api/v1/health" + }, + "security": { + "httpsRequired": true, + "insecureLoopbackAllowed": true + } +} +``` + +`instanceId` ilk discovery isteğinde kriptografik UUID olarak oluşturulur, `instance_settings` tablosunda saklanır ve backup/restore ile korunur. Domain veya marka adı instance kimliği değildir. + +## 4. API v1 envelope + +Başarılı yanıt: + +```json +{ + "ok": true, + "data": {} +} +``` + +Hata: + +```json +{ + "ok": false, + "error": { + "code": "UNAUTHENTICATED", + "message": "Geçerli bir oturum gerekli.", + "details": {} + } +} +``` + +`details` opsiyoneldir. `/api/v1` yanıtları `X-Neta-API-Version: 1` header'ı taşır. Mobil istemci kullanıcıya göstereceği metni `message` alanından alabilir; program akışını yalnızca stabil `code` üzerinden kurmalıdır. + +Mevcut hata kodları: + +- `VALIDATION_ERROR` +- `UNAUTHENTICATED` +- `FORBIDDEN` +- `NOT_FOUND` +- `CONFLICT` +- `INVARIANT_VIOLATION` +- `UPSTREAM_ERROR` +- `UPSTREAM_TIMEOUT` +- `SERVICE_UNAVAILABLE` +- `INTERNAL_ERROR` + +## 5. Metadata ve capability + +`/api/v1/meta` şu grupları döndürür: + +- Protokol/discovery/API major sürümü +- Neta server package sürümü +- Kalıcı instance kimliği ve oluşturulma zamanı +- Application/organization adı +- Mobil için absolute logo/icon URL'leri ve semantic marka bilgisi +- Minimum desteklenen mobil client sürümü +- `ios` ve `android` platform listesi +- Authentication durumu +- Capability listesi +- Discovery, API, health ve me linkleri + +Capability kaydı: + +```json +{ + "id": "portal.client", + "version": 1, + "status": "available", + "access": "client" +} +``` + +İstemci bilinmeyen capability ID ve alanlarını yok saymalıdır. `status=planned`, endpoint'in kullanılabilir olduğu anlamına gelmez. + +İlk capability seti: + +- `instance.discovery` +- `instance.branding` +- `auth.better-auth-cookie` +- `files.local` +- `freelancer.core` +- `portal.client` +- `ai.assistant` +- `auth.device-pairing` — `planned` + +## 6. Minimum mobil sürüm + +Instance sahibi opsiyonel SemVer tabanını environment ile ilan edebilir: + +```env +NETA_MINIMUM_MOBILE_VERSION=1.2.0 +``` + +Boşsa metadata `minimumSupportedVersion: null` döndürür ve server sürüm zorlaması yapmaz. İlk mobil release yayınlandığında istemci kendi sürümünü SemVer olarak karşılaştırır: + +- Client sürümü minimumdan düşükse authenticated mutation başlatmaz. +- Discovery/meta/health erişimini korur. +- Kullanıcıya upgrade gereksinimini gösterir. +- Pre-release SemVer yalnızca test kanallarında kullanılmalıdır. + +## 7. API sürümleme politikası + +- Major sürüm URL'dedir: `/api/v1`. +- Yeni opsiyonel alan, yeni endpoint ve yeni capability additive değişikliktir; v1 içinde yapılabilir. +- Alan silme, alan tipini değiştirme, mevcut enum anlamını değiştirme veya auth modelini kırma yeni major `/api/v2` gerektirir. +- ID'ler opaque string kabul edilir; UUID formatına client iş mantığı bağlanmaz. +- Timestamp'ler UTC ISO-8601 string'dir. +- Para değerleri ilgili resource API'leri yayınlandığında integer minor unit olacaktır. +- Liste endpoint'leri yayınlandığında cursor pagination kullanacaktır; offset sözleşmesi varsayılmaz. +- İstemci bilinmeyen JSON alanlarını ve enum/capability değerlerini forward-compatible biçimde yok sayar. +- Eski major kaldırılmadan önce metadata capability ve release notlarıyla deprecation duyurulur. + +## 8. Authentication sınırı + +Bugünkü `/me`, Better Auth cookie session'ını doğrular; web ve entegrasyon smoke testleri aynı güvenli session adapter'ını kullanır. Session token, password hash veya AI secret yanıt içine girmez. + +React Native için kalıcı bearer/device session üretimi bu fazda uygulanmadı. Mobil istemci `auth.device-pairing.status=planned` gördüğünde pairing UI'ını etkinleştirmemelidir. Gelecek güvenlik ve lifecycle kararı [ADR-0018](adr-0018-device-pairing.md) belgesindedir. + +## 9. Test ve kalite kapısı + +`pnpm phase9:smoke` şunları gerçek Next.js ve Better Auth akışında doğrular: + +- Eşzamanlı discovery isteklerinde tek ve kalıcı instance ID +- Discovery/meta ID ve absolute URL tutarlılığı +- Public cache ve session oluşturmama davranışı +- API version header ve envelope +- Readiness health +- Minimum client sürümü ve capability modeli +- Anonymous `/me` negatif testi +- Freelancer `/me` +- Client `/me` ve client kimlik bağı +- Disabled client session reddi +- Branding değişikliğinin absolute mobile metadata'ya yansıması +- Pairing route'larının implementasyon tamamlanmadan yayınlanmaması diff --git a/package.json b/package.json index bd919da..fef3195 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,9 @@ "phase7:backend-boundary": "node scripts/phase7-backend-boundary.mjs", "phase7:smoke": "node scripts/phase7-smoke.mjs", "phase8:import-smoke": "node scripts/phase8-import-smoke.mjs", - "phase8:release-boundary": "node scripts/phase8-release-boundary.mjs" + "phase8:release-boundary": "node scripts/phase8-release-boundary.mjs", + "phase9:api-boundary": "node scripts/phase9-api-boundary.mjs", + "phase9:smoke": "node scripts/phase9-smoke.mjs" }, "dependencies": { "@ai-sdk/google": "^3.0.80", diff --git a/scripts/phase1-auth-smoke.mjs b/scripts/phase1-auth-smoke.mjs index af59980..6a43462 100644 --- a/scripts/phase1-auth-smoke.mjs +++ b/scripts/phase1-auth-smoke.mjs @@ -21,6 +21,7 @@ const env = { NEXT_PUBLIC_SITE_URL: baseUrl, BETTER_AUTH_SECRET: "phase1-auth-smoke-secret-is-longer-than-32-characters", TRUSTED_ORIGINS: baseUrl, + NETA_MINIMUM_MOBILE_VERSION: "1.2.3-smoke.1", NEXT_TELEMETRY_DISABLED: "1", }; @@ -53,6 +54,79 @@ server.stderr.on("data", (chunk) => { try { await waitForServer(); + const discoveryResponses = await Promise.all( + Array.from({ length: 4 }, () => fetch(`${baseUrl}/.well-known/neta`)), + ); + const discoveryDocuments = await Promise.all( + discoveryResponses.map(async (response) => { + assert.equal(response.status, 200, "Neta discovery must be public"); + assert.match( + response.headers.get("cache-control") ?? "", + /public/, + "Discovery must declare its public cache policy", + ); + assert.equal(response.headers.get("set-cookie"), null, "Discovery must not create a session"); + return response.json(); + }), + ); + const discovery = discoveryDocuments[0]; + assert.equal(discovery.protocol, "neta"); + assert.equal(discovery.discoveryVersion, 1); + assert.match(discovery.instanceId, /^[0-9a-f-]{36}$/i); + assert.equal(discovery.api.version, "1"); + assert.equal(discovery.api.baseUrl, `${baseUrl}/api/v1`); + assert.equal(discovery.api.metaUrl, `${baseUrl}/api/v1/meta`); + assert.equal(discovery.security.httpsRequired, true); + assert.deepEqual( + new Set(discoveryDocuments.map((document) => document.instanceId)), + new Set([discovery.instanceId]), + "Concurrent discovery must return one stable instance id", + ); + + const publicMeta = await jsonRequest("/api/v1/meta"); + assert.equal(publicMeta.response.status, 200); + assert.equal(publicMeta.response.headers.get("x-neta-api-version"), "1"); + assert.equal(publicMeta.payload.ok, true); + assert.equal(publicMeta.payload.data.instance.id, discovery.instanceId); + assert.match( + publicMeta.payload.data.instance.createdAt, + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, + "Instance timestamps must use UTC ISO-8601", + ); + assert.equal(publicMeta.payload.data.client.minimumSupportedVersion, "1.2.3-smoke.1"); + assert.equal(publicMeta.payload.data.links.me, `${baseUrl}/api/v1/me`); + assert.deepEqual(publicMeta.payload.data.client.platforms, ["ios", "android"]); + assert.deepEqual( + publicMeta.payload.data.capabilities.find( + (capability) => capability.id === "auth.device-pairing", + ), + { + id: "auth.device-pairing", + version: 1, + status: "planned", + access: "freelancer", + }, + ); + + const publicV1Health = await jsonRequest("/api/v1/health"); + assert.equal(publicV1Health.response.status, 200); + assert.deepEqual( + { + ok: publicV1Health.payload.ok, + status: publicV1Health.payload.data.status, + migrationsApplied: publicV1Health.payload.data.checks.migrationsApplied, + }, + { ok: true, status: "ok", migrationsApplied: true }, + ); + + const anonymousMe = await jsonRequest("/api/v1/me"); + assert.equal(anonymousMe.response.status, 401); + assert.equal(anonymousMe.response.headers.get("x-neta-api-version"), "1"); + assert.deepEqual( + { ok: anonymousMe.payload.ok, code: anonymousMe.payload.error.code }, + { ok: false, code: "UNAUTHENTICATED" }, + ); + const setupAttempts = await Promise.all([ authPost("/api/auth/sign-up/email", { name: "Owner One", @@ -83,6 +157,32 @@ try { const ownerUserId = db .prepare("select auth_user_id as authUserId from app_profiles where role = 'freelancer'") .get().authUserId; + assert.deepEqual( + db.prepare("select instance_id as instanceId from instance_settings").all(), + [{ instanceId: discovery.instanceId }], + "Discovery identity must be persisted exactly once", + ); + const ownerMe = await jsonRequest("/api/v1/me", { cookie: ownerCookie }); + assert.equal(ownerMe.response.status, 200); + assert.deepEqual( + { + id: ownerMe.payload.data.user.id, + email: ownerMe.payload.data.user.email, + role: ownerMe.payload.data.user.role, + clientId: ownerMe.payload.data.user.clientId, + }, + { + id: ownerUserId, + email: ownerEmail, + role: "freelancer", + clientId: null, + }, + ); + assert.doesNotMatch( + JSON.stringify(ownerMe.payload), + /token|password/i, + "The me contract must not expose session tokens or password material", + ); const insertClient = db.prepare( "insert into clients (id, owner_user_id, name) values (?, ?, ?)", ); @@ -257,6 +357,13 @@ try { assert.match(brandedLoginHtml, /--poyraz-primary:#336699/, "Brand tokens must be present in first HTML response"); const dynamicManifest = await (await fetch(`${baseUrl}/manifest.webmanifest`)).json(); assert.equal(dynamicManifest.name, "Neta Smoke Studio", "Manifest must use instance branding"); + const brandedMeta = await jsonRequest("/api/v1/meta"); + assert.equal(brandedMeta.payload.data.instance.applicationName, "Neta Smoke Studio"); + assert.equal( + brandedMeta.payload.data.branding.lightLogoUrl, + `${baseUrl}/api/branding/assets/${logoFileId}`, + "Mobile metadata must expose absolute branding asset URLs", + ); const publicLogo = await fetch(`${baseUrl}/api/branding/assets/${logoFileId}`); assert.equal(publicLogo.status, 200, "Referenced branding asset must be publicly readable"); assert.equal(publicLogo.headers.get("x-content-type-options"), "nosniff"); @@ -371,6 +478,15 @@ try { }); assert.equal(clientSignIn.response.ok, true, JSON.stringify(clientSignIn.payload)); const clientCookie = cookieHeader(clientSignIn.response); + const clientMe = await jsonRequest("/api/v1/me", { cookie: clientCookie }); + assert.equal(clientMe.response.status, 200); + assert.deepEqual( + { + role: clientMe.payload.data.user.role, + clientId: clientMe.payload.data.user.clientId, + }, + { role: "client", clientId: "client-alpha" }, + ); for (const [pathname, body] of [ ["/api/finance-analysis", undefined], @@ -477,6 +593,8 @@ try { headers: { cookie: clientCookie }, }); assert.equal((await revokedSession.json()), null, "Disabling a client must revoke active sessions"); + const revokedClientMe = await jsonRequest("/api/v1/me", { cookie: clientCookie }); + assert.equal(revokedClientMe.response.status, 401, "Disabled client API session must be rejected"); const disabledSignIn = await authPost("/api/auth/sign-in/email", { email: "client@example.com", diff --git a/scripts/phase9-api-boundary.mjs b/scripts/phase9-api-boundary.mjs new file mode 100644 index 0000000..8d1ab96 --- /dev/null +++ b/scripts/phase9-api-boundary.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const repoRoot = process.cwd(); +const requiredRoutes = [ + "app/.well-known/neta/route.ts", + "app/api/v1/meta/route.ts", + "app/api/v1/health/route.ts", + "app/api/v1/me/route.ts", +]; +for (const route of requiredRoutes) { + assert.ok(fs.existsSync(path.join(repoRoot, route)), `Missing Phase 9 route: ${route}`); +} + +const contracts = read("server/api/v1/contracts.ts"); +for (const value of [ + 'NETA_PROTOCOL = "neta"', + "NETA_DISCOVERY_VERSION = 1", + 'NETA_API_VERSION = "1"', + '"auth.device-pairing"', + 'status: "planned"', + "minimumSupportedVersion", +]) { + assert.ok(contracts.includes(value), `Missing API contract marker: ${value}`); +} + +const instanceService = read("server/instance/service.ts"); +assert.doesNotMatch( + instanceService, + /next\/|Request\b|Response\b|cookies?\b|headers?\b/i, + "Instance service must stay independent from Next.js transport objects", +); + +const discovery = read("app/.well-known/neta/route.ts"); +assert.doesNotMatch( + discovery, + /getSession|requireSession|authorization/i, + "Discovery must remain public and session-independent", +); + +for (const route of requiredRoutes.slice(1)) { + const content = read(route); + assert.match(content, /apiV1(?:Success|Error)/, `${route} must use the v1 envelope`); +} + +for (const futureRoute of [ + "app/api/v1/pairing-codes", + "app/api/v1/device-sessions", +]) { + assert.equal( + fs.existsSync(path.join(repoRoot, futureRoute)), + false, + `${futureRoute} must not ship before the pairing security design is implemented`, + ); +} + +const runtimeFiles = [ + ...requiredRoutes, + "server/api/v1/contracts.ts", + "server/api/v1/responses.ts", + "server/api/v1/runtime.ts", + "server/instance/service.ts", + "server/instance/runtime.ts", + "server/repositories/instance.ts", +]; +for (const file of runtimeFiles) { + assert.doesNotMatch(read(file), /@supabase\/|supabase\.co/i, `Supabase reference in ${file}`); +} + +console.log("Phase 9 API boundary passed: discovery, v1 contracts and pairing scope verified."); + +function read(relativePath) { + return fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); +} diff --git a/scripts/phase9-smoke.mjs b/scripts/phase9-smoke.mjs new file mode 100644 index 0000000..1d4b759 --- /dev/null +++ b/scripts/phase9-smoke.mjs @@ -0,0 +1,10 @@ +import { execFileSync } from "node:child_process"; + +for (const [command, args] of [ + [process.execPath, ["scripts/phase9-api-boundary.mjs"]], + [process.execPath, ["scripts/phase1-auth-smoke.mjs"]], +]) { + execFileSync(command, args, { cwd: process.cwd(), stdio: "inherit" }); +} + +console.log("Phase 9 mobile API smoke passed."); diff --git a/server/api/v1/contracts.ts b/server/api/v1/contracts.ts new file mode 100644 index 0000000..f5751a7 --- /dev/null +++ b/server/api/v1/contracts.ts @@ -0,0 +1,171 @@ +import type { PublicBranding } from "../../branding/service"; +import type { InstanceIdentity } from "../../instance/service"; + +export const NETA_PROTOCOL = "neta" as const; +export const NETA_DISCOVERY_VERSION = 1 as const; +export const NETA_API_VERSION = "1" as const; +export const NETA_API_BASE_PATH = "/api/v1" as const; + +export type CapabilityStatus = "available" | "planned"; +export type CapabilityAccess = "public" | "session" | "freelancer" | "client"; + +export type NetaCapability = { + id: string; + version: number; + status: CapabilityStatus; + access: CapabilityAccess; +}; + +export const NETA_CAPABILITIES = [ + { id: "instance.discovery", version: 1, status: "available", access: "public" }, + { id: "instance.branding", version: 1, status: "available", access: "public" }, + { id: "auth.better-auth-cookie", version: 1, status: "available", access: "session" }, + { id: "files.local", version: 1, status: "available", access: "session" }, + { id: "freelancer.core", version: 1, status: "available", access: "freelancer" }, + { id: "portal.client", version: 1, status: "available", access: "client" }, + { id: "ai.assistant", version: 1, status: "available", access: "freelancer" }, + { id: "auth.device-pairing", version: 1, status: "planned", access: "freelancer" }, +] as const satisfies readonly NetaCapability[]; + +export type NetaDiscoveryDocument = { + protocol: typeof NETA_PROTOCOL; + discoveryVersion: typeof NETA_DISCOVERY_VERSION; + instanceId: string; + applicationName: string; + api: { + version: typeof NETA_API_VERSION; + baseUrl: string; + metaUrl: string; + healthUrl: string; + }; + security: { + httpsRequired: true; + insecureLoopbackAllowed: true; + }; +}; + +export type NetaInstanceMetadata = { + protocol: { + name: typeof NETA_PROTOCOL; + discoveryVersion: typeof NETA_DISCOVERY_VERSION; + apiVersion: typeof NETA_API_VERSION; + }; + server: { + version: string; + }; + instance: { + id: string; + createdAt: string; + applicationName: string; + shortName: string; + organizationName: string | null; + }; + branding: { + primaryColor: string; + accentColor: string; + defaultColorMode: PublicBranding["defaultColorMode"]; + radiusScale: PublicBranding["radiusScale"]; + lightLogoUrl: string | null; + darkLogoUrl: string | null; + iconUrl: string | null; + }; + client: { + minimumSupportedVersion: string | null; + platforms: readonly ["ios", "android"]; + }; + authentication: { + sessionMethod: "better-auth-cookie"; + devicePairing: "planned"; + }; + capabilities: readonly NetaCapability[]; + links: { + discovery: string; + apiBase: string; + health: string; + me: string; + }; +}; + +type ContractInput = { + appUrl: string; + serverVersion: string; + minimumMobileClientVersion: string | null; + identity: InstanceIdentity; + branding: PublicBranding; +}; + +export function buildDiscoveryDocument( + input: ContractInput, +): NetaDiscoveryDocument { + const apiBaseUrl = absoluteUrl(input.appUrl, NETA_API_BASE_PATH); + return { + protocol: NETA_PROTOCOL, + discoveryVersion: NETA_DISCOVERY_VERSION, + instanceId: input.identity.instanceId, + applicationName: input.branding.applicationName, + api: { + version: NETA_API_VERSION, + baseUrl: apiBaseUrl, + metaUrl: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/meta`), + healthUrl: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/health`), + }, + security: { + httpsRequired: true, + insecureLoopbackAllowed: true, + }, + }; +} + +export function buildInstanceMetadata( + input: ContractInput, +): NetaInstanceMetadata { + return { + protocol: { + name: NETA_PROTOCOL, + discoveryVersion: NETA_DISCOVERY_VERSION, + apiVersion: NETA_API_VERSION, + }, + server: { + version: input.serverVersion, + }, + instance: { + id: input.identity.instanceId, + createdAt: input.identity.createdAt, + applicationName: input.branding.applicationName, + shortName: input.branding.shortName, + organizationName: input.branding.organizationName, + }, + branding: { + primaryColor: input.branding.primaryColor, + accentColor: input.branding.accentColor, + defaultColorMode: input.branding.defaultColorMode, + radiusScale: input.branding.radiusScale, + lightLogoUrl: absoluteOptionalUrl(input.appUrl, input.branding.lightLogoUrl), + darkLogoUrl: absoluteOptionalUrl(input.appUrl, input.branding.darkLogoUrl), + iconUrl: absoluteOptionalUrl(input.appUrl, input.branding.iconUrl), + }, + client: { + minimumSupportedVersion: input.minimumMobileClientVersion, + platforms: ["ios", "android"], + }, + authentication: { + sessionMethod: "better-auth-cookie", + devicePairing: "planned", + }, + capabilities: NETA_CAPABILITIES, + links: { + discovery: absoluteUrl(input.appUrl, "/.well-known/neta"), + apiBase: absoluteUrl(input.appUrl, NETA_API_BASE_PATH), + health: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/health`), + me: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/me`), + }, + }; +} + +function absoluteOptionalUrl(baseUrl: string, value: string | null): string | null { + return value ? absoluteUrl(baseUrl, value) : null; +} + +function absoluteUrl(baseUrl: string, pathname: string): string { + return new URL(pathname, `${baseUrl}/`).toString(); +} diff --git a/server/api/v1/responses.ts b/server/api/v1/responses.ts new file mode 100644 index 0000000..3435fd2 --- /dev/null +++ b/server/api/v1/responses.ts @@ -0,0 +1,24 @@ +import "server-only"; + +import type { NextResponse } from "next/server"; +import { apiError, apiSuccess } from "../responses"; +import { NETA_API_VERSION } from "./contracts"; + +export function apiV1Success( + data: T, + init?: ResponseInit, +): NextResponse { + return withV1Headers(apiSuccess(data, init)); +} + +export function apiV1Error(error: unknown): NextResponse { + return withV1Headers(apiError(error)); +} + +function withV1Headers(response: NextResponse): NextResponse { + response.headers.set("X-Neta-API-Version", NETA_API_VERSION); + if (!response.headers.has("Cache-Control")) { + response.headers.set("Cache-Control", "private, no-store"); + } + return response; +} diff --git a/server/api/v1/runtime.ts b/server/api/v1/runtime.ts new file mode 100644 index 0000000..19ea4e3 --- /dev/null +++ b/server/api/v1/runtime.ts @@ -0,0 +1,29 @@ +import "server-only"; + +import packageJson from "../../../package.json"; +import { getPublicBranding } from "../../branding/runtime"; +import { getServerConfig } from "../../config"; +import { getInstanceService } from "../../instance/runtime"; +import { + buildDiscoveryDocument, + buildInstanceMetadata, +} from "./contracts"; + +export function getNetaDiscoveryDocument() { + return buildDiscoveryDocument(getContractInput()); +} + +export function getNetaInstanceMetadata() { + return buildInstanceMetadata(getContractInput()); +} + +function getContractInput() { + const config = getServerConfig(); + return { + appUrl: config.appUrl, + serverVersion: packageJson.version, + minimumMobileClientVersion: config.minimumMobileClientVersion, + identity: getInstanceService().getIdentity(), + branding: getPublicBranding(), + }; +} diff --git a/server/config.ts b/server/config.ts index a489575..d484239 100644 --- a/server/config.ts +++ b/server/config.ts @@ -16,6 +16,11 @@ const envSchema = z.object({ DATABASE_PATH: z.string().trim().optional(), OLLAMA_BASE_URL: z.string().url().optional(), AI_REQUEST_TIMEOUT_MS: z.coerce.number().int().min(1_000).max(120_000).optional(), + NETA_MINIMUM_MOBILE_VERSION: z + .string() + .trim() + .regex(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/) + .optional(), }); export type ServerConfig = { @@ -31,6 +36,7 @@ export type ServerConfig = { betterAuthSecret?: string; ollamaBaseUrl: string; aiRequestTimeoutMs: number; + minimumMobileClientVersion: string | null; }; let cachedConfig: ServerConfig | undefined; @@ -82,6 +88,7 @@ export function getServerConfig(): ServerConfig { betterAuthSecret, ollamaBaseUrl: parsed.OLLAMA_BASE_URL ?? "http://127.0.0.1:11434/v1", aiRequestTimeoutMs: parsed.AI_REQUEST_TIMEOUT_MS ?? 30_000, + minimumMobileClientVersion: parsed.NETA_MINIMUM_MOBILE_VERSION ?? null, }; return cachedConfig; diff --git a/server/db/migrations/0007_flaky_kinsey_walden.sql b/server/db/migrations/0007_flaky_kinsey_walden.sql new file mode 100644 index 0000000..22a8a88 --- /dev/null +++ b/server/db/migrations/0007_flaky_kinsey_walden.sql @@ -0,0 +1,8 @@ +CREATE TABLE `instance_settings` ( + `key` text PRIMARY KEY NOT NULL, + `instance_id` text NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `instance_settings_instance_id_unique` ON `instance_settings` (`instance_id`); \ No newline at end of file diff --git a/server/db/migrations/meta/0007_snapshot.json b/server/db/migrations/meta/0007_snapshot.json new file mode 100644 index 0000000..c00d730 --- /dev/null +++ b/server/db/migrations/meta/0007_snapshot.json @@ -0,0 +1,3827 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "ab43cdad-01c9-40ac-80f1-de43e4f882b1", + "prevId": "1c9ab6a5-0948-44d7-9c65-03e6ea99db34", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_profiles": { + "name": "app_profiles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "auth_user_id": { + "name": "auth_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disabled": { + "name": "disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "app_profiles_auth_user_id_unique": { + "name": "app_profiles_auth_user_id_unique", + "columns": [ + "auth_user_id" + ], + "isUnique": true + }, + "app_profiles_client_id_unique": { + "name": "app_profiles_client_id_unique", + "columns": [ + "client_id" + ], + "isUnique": true + }, + "app_profiles_role_idx": { + "name": "app_profiles_role_idx", + "columns": [ + "role" + ], + "isUnique": false + } + }, + "foreignKeys": { + "app_profiles_auth_user_id_user_id_fk": { + "name": "app_profiles_auth_user_id_user_id_fk", + "tableFrom": "app_profiles", + "tableTo": "user", + "columnsFrom": [ + "auth_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_setup_state": { + "name": "app_setup_state", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_by": { + "name": "locked_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locked_at": { + "name": "locked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_audit_events": { + "name": "auth_audit_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_user_id": { + "name": "auth_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "auth_audit_events_type_idx": { + "name": "auth_audit_events_type_idx", + "columns": [ + "type" + ], + "isUnique": false + }, + "auth_audit_events_auth_user_id_idx": { + "name": "auth_audit_events_auth_user_id_idx", + "columns": [ + "auth_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "auth_audit_events_auth_user_id_user_id_fk": { + "name": "auth_audit_events_auth_user_id_user_id_fk", + "tableFrom": "auth_audit_events", + "tableTo": "user", + "columnsFrom": [ + "auth_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "portal_invitations": { + "name": "portal_invitations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "portal_invitations_token_hash_unique": { + "name": "portal_invitations_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + }, + "portal_invitations_client_id_idx": { + "name": "portal_invitations_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "portal_invitations_email_idx": { + "name": "portal_invitations_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "portal_invitations_created_by_user_id_user_id_fk": { + "name": "portal_invitations_created_by_user_id_user_id_fk", + "tableFrom": "portal_invitations", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "calendar_events": { + "name": "calendar_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'focus'" + }, + "starts_at": { + "name": "starts_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "calendar_events_owner_range_idx": { + "name": "calendar_events_owner_range_idx", + "columns": [ + "owner_user_id", + "starts_at" + ], + "isUnique": false + }, + "calendar_events_project_id_idx": { + "name": "calendar_events_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "calendar_events_task_id_idx": { + "name": "calendar_events_task_id_idx", + "columns": [ + "task_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "calendar_events_owner_user_id_user_id_fk": { + "name": "calendar_events_owner_user_id_user_id_fk", + "tableFrom": "calendar_events", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "calendar_events_client_id_clients_id_fk": { + "name": "calendar_events_client_id_clients_id_fk", + "tableFrom": "calendar_events", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "calendar_events_project_id_projects_id_fk": { + "name": "calendar_events_project_id_projects_id_fk", + "tableFrom": "calendar_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "calendar_events_task_id_tasks_id_fk": { + "name": "calendar_events_task_id_tasks_id_fk", + "tableFrom": "calendar_events", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "calendar_events_type_check": { + "name": "calendar_events_type_check", + "value": "\"calendar_events\".\"type\" in ('meeting', 'focus', 'deadline', 'personal', 'finance')" + }, + "calendar_events_time_check": { + "name": "calendar_events_time_check", + "value": "\"calendar_events\".\"ends_at\" is null or \"calendar_events\".\"ends_at\" >= \"calendar_events\".\"starts_at\"" + } + } + }, + "chat_messages": { + "name": "chat_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_journal_entry_ids": { + "name": "context_journal_entry_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "chat_messages_session_created_idx": { + "name": "chat_messages_session_created_idx", + "columns": [ + "session_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_messages_session_id_chat_sessions_id_fk": { + "name": "chat_messages_session_id_chat_sessions_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "chat_messages_role_check": { + "name": "chat_messages_role_check", + "value": "\"chat_messages\".\"role\" in ('system', 'user', 'assistant', 'tool')" + } + } + }, + "chat_sessions": { + "name": "chat_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "chat_sessions_owner_updated_idx": { + "name": "chat_sessions_owner_updated_idx", + "columns": [ + "owner_user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_sessions_owner_user_id_user_id_fk": { + "name": "chat_sessions_owner_user_id_user_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "client_activities": { + "name": "client_activities", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "activity_date": { + "name": "activity_date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "client_activities_owner_client_idx": { + "name": "client_activities_owner_client_idx", + "columns": [ + "owner_user_id", + "client_id" + ], + "isUnique": false + }, + "client_activities_client_date_idx": { + "name": "client_activities_client_date_idx", + "columns": [ + "client_id", + "activity_date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "client_activities_owner_user_id_user_id_fk": { + "name": "client_activities_owner_user_id_user_id_fk", + "tableFrom": "client_activities", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_activities_client_id_clients_id_fk": { + "name": "client_activities_client_id_clients_id_fk", + "tableFrom": "client_activities", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "client_activities_type_check": { + "name": "client_activities_type_check", + "value": "\"client_activities\".\"type\" in ('note', 'call', 'meeting', 'email')" + } + } + }, + "clients": { + "name": "clients", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_user_id": { + "name": "auth_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "pipeline_stage": { + "name": "pipeline_stage", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'lead'" + }, + "next_follow_up_date": { + "name": "next_follow_up_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "clients_auth_user_id_unique": { + "name": "clients_auth_user_id_unique", + "columns": [ + "auth_user_id" + ], + "isUnique": true + }, + "clients_owner_user_id_idx": { + "name": "clients_owner_user_id_idx", + "columns": [ + "owner_user_id" + ], + "isUnique": false + }, + "clients_owner_status_idx": { + "name": "clients_owner_status_idx", + "columns": [ + "owner_user_id", + "status" + ], + "isUnique": false + }, + "clients_owner_pipeline_idx": { + "name": "clients_owner_pipeline_idx", + "columns": [ + "owner_user_id", + "pipeline_stage" + ], + "isUnique": false + }, + "clients_next_follow_up_date_idx": { + "name": "clients_next_follow_up_date_idx", + "columns": [ + "next_follow_up_date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "clients_owner_user_id_user_id_fk": { + "name": "clients_owner_user_id_user_id_fk", + "tableFrom": "clients", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "clients_auth_user_id_user_id_fk": { + "name": "clients_auth_user_id_user_id_fk", + "tableFrom": "clients", + "tableTo": "user", + "columnsFrom": [ + "auth_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "clients_status_check": { + "name": "clients_status_check", + "value": "\"clients\".\"status\" in ('active', 'paused', 'archived')" + }, + "clients_pipeline_stage_check": { + "name": "clients_pipeline_stage_check", + "value": "\"clients\".\"pipeline_stage\" in ('lead', 'contacted', 'proposal_sent', 'won', 'lost')" + } + } + }, + "contracts": { + "name": "contracts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "proposal_id": { + "name": "proposal_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "signed_at": { + "name": "signed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "contracts_owner_status_idx": { + "name": "contracts_owner_status_idx", + "columns": [ + "owner_user_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "contracts_owner_user_id_user_id_fk": { + "name": "contracts_owner_user_id_user_id_fk", + "tableFrom": "contracts", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contracts_proposal_id_proposals_id_fk": { + "name": "contracts_proposal_id_proposals_id_fk", + "tableFrom": "contracts", + "tableTo": "proposals", + "columnsFrom": [ + "proposal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "contracts_client_id_clients_id_fk": { + "name": "contracts_client_id_clients_id_fk", + "tableFrom": "contracts", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "contracts_status_check": { + "name": "contracts_status_check", + "value": "\"contracts\".\"status\" in ('draft', 'active', 'completed', 'cancelled')" + } + } + }, + "finance_transactions": { + "name": "finance_transactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_minor": { + "name": "amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "transaction_date": { + "name": "transaction_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'planned'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "finance_transactions_owner_date_idx": { + "name": "finance_transactions_owner_date_idx", + "columns": [ + "owner_user_id", + "transaction_date" + ], + "isUnique": false + }, + "finance_transactions_owner_type_idx": { + "name": "finance_transactions_owner_type_idx", + "columns": [ + "owner_user_id", + "type" + ], + "isUnique": false + }, + "finance_transactions_client_id_idx": { + "name": "finance_transactions_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "finance_transactions_project_id_idx": { + "name": "finance_transactions_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "finance_transactions_owner_user_id_user_id_fk": { + "name": "finance_transactions_owner_user_id_user_id_fk", + "tableFrom": "finance_transactions", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "finance_transactions_client_id_clients_id_fk": { + "name": "finance_transactions_client_id_clients_id_fk", + "tableFrom": "finance_transactions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "finance_transactions_project_id_projects_id_fk": { + "name": "finance_transactions_project_id_projects_id_fk", + "tableFrom": "finance_transactions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "finance_transactions_type_check": { + "name": "finance_transactions_type_check", + "value": "\"finance_transactions\".\"type\" in ('income', 'expense')" + }, + "finance_transactions_amount_check": { + "name": "finance_transactions_amount_check", + "value": "\"finance_transactions\".\"amount_minor\" >= 0" + }, + "finance_transactions_payment_status_check": { + "name": "finance_transactions_payment_status_check", + "value": "\"finance_transactions\".\"payment_status\" in ('planned', 'pending', 'paid', 'cancelled')" + }, + "finance_transactions_currency_check": { + "name": "finance_transactions_currency_check", + "value": "length(\"finance_transactions\".\"currency\") = 3" + } + } + }, + "invoices": { + "name": "invoices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_minor": { + "name": "amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "tax_basis_points": { + "name": "tax_basis_points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'TRY'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "issue_date": { + "name": "issue_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_at": { + "name": "paid_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "invoices_owner_number_unique": { + "name": "invoices_owner_number_unique", + "columns": [ + "owner_user_id", + "invoice_number" + ], + "isUnique": true + }, + "invoices_owner_status_idx": { + "name": "invoices_owner_status_idx", + "columns": [ + "owner_user_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invoices_owner_user_id_user_id_fk": { + "name": "invoices_owner_user_id_user_id_fk", + "tableFrom": "invoices", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoices_client_id_clients_id_fk": { + "name": "invoices_client_id_clients_id_fk", + "tableFrom": "invoices", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "invoices_project_id_projects_id_fk": { + "name": "invoices_project_id_projects_id_fk", + "tableFrom": "invoices", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "invoices_status_check": { + "name": "invoices_status_check", + "value": "\"invoices\".\"status\" in ('draft', 'sent', 'paid', 'overdue', 'cancelled')" + }, + "invoices_amount_check": { + "name": "invoices_amount_check", + "value": "\"invoices\".\"amount_minor\" >= 0" + }, + "invoices_tax_check": { + "name": "invoices_tax_check", + "value": "\"invoices\".\"tax_basis_points\" between 0 and 10000" + } + } + }, + "journal_entries": { + "name": "journal_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entry_date": { + "name": "entry_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mood_score": { + "name": "mood_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "energy_score": { + "name": "energy_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "work_satisfaction_score": { + "name": "work_satisfaction_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mood_label": { + "name": "mood_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_ai_metadata": { + "name": "legacy_ai_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "journal_entries_owner_date_unique": { + "name": "journal_entries_owner_date_unique", + "columns": [ + "owner_user_id", + "entry_date" + ], + "isUnique": true + }, + "journal_entries_owner_date_idx": { + "name": "journal_entries_owner_date_idx", + "columns": [ + "owner_user_id", + "entry_date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "journal_entries_owner_user_id_user_id_fk": { + "name": "journal_entries_owner_user_id_user_id_fk", + "tableFrom": "journal_entries", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "journal_entries_mood_score_check": { + "name": "journal_entries_mood_score_check", + "value": "\"journal_entries\".\"mood_score\" is null or \"journal_entries\".\"mood_score\" between 1 and 5" + }, + "journal_entries_energy_score_check": { + "name": "journal_entries_energy_score_check", + "value": "\"journal_entries\".\"energy_score\" is null or \"journal_entries\".\"energy_score\" between 1 and 5" + }, + "journal_entries_work_score_check": { + "name": "journal_entries_work_score_check", + "value": "\"journal_entries\".\"work_satisfaction_score\" is null or \"journal_entries\".\"work_satisfaction_score\" between 1 and 5" + } + } + }, + "project_planning_sections": { + "name": "project_planning_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "project_planning_sections_owner_idx": { + "name": "project_planning_sections_owner_idx", + "columns": [ + "owner_user_id" + ], + "isUnique": false + }, + "project_planning_sections_project_order_idx": { + "name": "project_planning_sections_project_order_idx", + "columns": [ + "project_id", + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": { + "project_planning_sections_owner_user_id_user_id_fk": { + "name": "project_planning_sections_owner_user_id_user_id_fk", + "tableFrom": "project_planning_sections", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_planning_sections_project_id_projects_id_fk": { + "name": "project_planning_sections_project_id_projects_id_fk", + "tableFrom": "project_planning_sections", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_planning_sections_category_check": { + "name": "project_planning_sections_category_check", + "value": "\"project_planning_sections\".\"category\" in ('overview', 'problem', 'goal', 'audience', 'scope', 'design_system', 'color_palette', 'typography', 'assets', 'notes')" + } + } + }, + "project_revisions": { + "name": "project_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "project_revisions_owner_project_idx": { + "name": "project_revisions_owner_project_idx", + "columns": [ + "owner_user_id", + "project_id" + ], + "isUnique": false + }, + "project_revisions_client_project_idx": { + "name": "project_revisions_client_project_idx", + "columns": [ + "client_id", + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "project_revisions_owner_user_id_user_id_fk": { + "name": "project_revisions_owner_user_id_user_id_fk", + "tableFrom": "project_revisions", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_revisions_project_id_projects_id_fk": { + "name": "project_revisions_project_id_projects_id_fk", + "tableFrom": "project_revisions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_revisions_client_id_clients_id_fk": { + "name": "project_revisions_client_id_clients_id_fk", + "tableFrom": "project_revisions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_revisions_requested_by_user_id_user_id_fk": { + "name": "project_revisions_requested_by_user_id_user_id_fk", + "tableFrom": "project_revisions", + "tableTo": "user", + "columnsFrom": [ + "requested_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_revisions_status_check": { + "name": "project_revisions_status_check", + "value": "\"project_revisions\".\"status\" in ('pending', 'in_progress', 'completed', 'rejected')" + } + } + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'client_project'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'planning'" + }, + "start_date": { + "name": "start_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "budget_amount_minor": { + "name": "budget_amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "progress_type": { + "name": "progress_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "revision_quota": { + "name": "revision_quota", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "legacy_cover_image_path": { + "name": "legacy_cover_image_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_image_alt": { + "name": "cover_image_alt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "projects_owner_user_id_idx": { + "name": "projects_owner_user_id_idx", + "columns": [ + "owner_user_id" + ], + "isUnique": false + }, + "projects_owner_status_idx": { + "name": "projects_owner_status_idx", + "columns": [ + "owner_user_id", + "status" + ], + "isUnique": false + }, + "projects_client_id_idx": { + "name": "projects_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "projects_due_date_idx": { + "name": "projects_due_date_idx", + "columns": [ + "due_date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "projects_owner_user_id_user_id_fk": { + "name": "projects_owner_user_id_user_id_fk", + "tableFrom": "projects", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_client_id_clients_id_fk": { + "name": "projects_client_id_clients_id_fk", + "tableFrom": "projects", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "projects_type_check": { + "name": "projects_type_check", + "value": "\"projects\".\"type\" in ('client_project', 'side_project')" + }, + "projects_status_check": { + "name": "projects_status_check", + "value": "\"projects\".\"status\" in ('planning', 'active', 'paused', 'completed', 'cancelled')" + }, + "projects_progress_check": { + "name": "projects_progress_check", + "value": "\"projects\".\"progress\" between 0 and 100" + }, + "projects_revision_quota_check": { + "name": "projects_revision_quota_check", + "value": "\"projects\".\"revision_quota\" >= 0" + }, + "projects_budget_check": { + "name": "projects_budget_check", + "value": "\"projects\".\"budget_amount_minor\" is null or \"projects\".\"budget_amount_minor\" >= 0" + }, + "projects_currency_check": { + "name": "projects_currency_check", + "value": "length(\"projects\".\"currency\") = 3" + } + } + }, + "proposals": { + "name": "proposals", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount_minor": { + "name": "amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'TRY'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "valid_until": { + "name": "valid_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "proposals_owner_status_idx": { + "name": "proposals_owner_status_idx", + "columns": [ + "owner_user_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "proposals_owner_user_id_user_id_fk": { + "name": "proposals_owner_user_id_user_id_fk", + "tableFrom": "proposals", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proposals_client_id_clients_id_fk": { + "name": "proposals_client_id_clients_id_fk", + "tableFrom": "proposals", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "proposals_project_id_projects_id_fk": { + "name": "proposals_project_id_projects_id_fk", + "tableFrom": "proposals", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "proposals_status_check": { + "name": "proposals_status_check", + "value": "\"proposals\".\"status\" in ('draft', 'sent', 'accepted', 'rejected')" + }, + "proposals_amount_check": { + "name": "proposals_amount_check", + "value": "\"proposals\".\"amount_minor\" >= 0" + } + } + }, + "subscriptions": { + "name": "subscriptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_minor": { + "name": "amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'TRY'" + }, + "billing_cycle": { + "name": "billing_cycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'monthly'" + }, + "next_billing_date": { + "name": "next_billing_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "subscriptions_owner_status_idx": { + "name": "subscriptions_owner_status_idx", + "columns": [ + "owner_user_id", + "status" + ], + "isUnique": false + }, + "subscriptions_next_billing_date_idx": { + "name": "subscriptions_next_billing_date_idx", + "columns": [ + "next_billing_date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "subscriptions_owner_user_id_user_id_fk": { + "name": "subscriptions_owner_user_id_user_id_fk", + "tableFrom": "subscriptions", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "subscriptions_cycle_check": { + "name": "subscriptions_cycle_check", + "value": "\"subscriptions\".\"billing_cycle\" in ('weekly', 'monthly', 'yearly')" + }, + "subscriptions_status_check": { + "name": "subscriptions_status_check", + "value": "\"subscriptions\".\"status\" in ('active', 'cancelled')" + }, + "subscriptions_amount_check": { + "name": "subscriptions_amount_check", + "value": "\"subscriptions\".\"amount_minor\" >= 0" + } + } + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_journal_entry_id": { + "name": "source_journal_entry_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'todo'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'medium'" + }, + "scheduled_date": { + "name": "scheduled_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_at": { + "name": "due_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimated_minutes": { + "name": "estimated_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actual_minutes": { + "name": "actual_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ai_generated": { + "name": "ai_generated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_public_to_client": { + "name": "is_public_to_client", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "tasks_owner_user_id_idx": { + "name": "tasks_owner_user_id_idx", + "columns": [ + "owner_user_id" + ], + "isUnique": false + }, + "tasks_owner_status_idx": { + "name": "tasks_owner_status_idx", + "columns": [ + "owner_user_id", + "status" + ], + "isUnique": false + }, + "tasks_project_id_idx": { + "name": "tasks_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "tasks_client_id_idx": { + "name": "tasks_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "tasks_due_at_idx": { + "name": "tasks_due_at_idx", + "columns": [ + "due_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_owner_user_id_user_id_fk": { + "name": "tasks_owner_user_id_user_id_fk", + "tableFrom": "tasks", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_client_id_clients_id_fk": { + "name": "tasks_client_id_clients_id_fk", + "tableFrom": "tasks", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_source_journal_entry_id_journal_entries_id_fk": { + "name": "tasks_source_journal_entry_id_journal_entries_id_fk", + "tableFrom": "tasks", + "tableTo": "journal_entries", + "columnsFrom": [ + "source_journal_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "tasks_status_check": { + "name": "tasks_status_check", + "value": "\"tasks\".\"status\" in ('todo', 'in_progress', 'done', 'cancelled')" + }, + "tasks_priority_check": { + "name": "tasks_priority_check", + "value": "\"tasks\".\"priority\" in ('low', 'medium', 'high', 'urgent')" + }, + "tasks_estimated_minutes_check": { + "name": "tasks_estimated_minutes_check", + "value": "\"tasks\".\"estimated_minutes\" is null or \"tasks\".\"estimated_minutes\" >= 0" + }, + "tasks_actual_minutes_check": { + "name": "tasks_actual_minutes_check", + "value": "\"tasks\".\"actual_minutes\" is null or \"tasks\".\"actual_minutes\" >= 0" + } + } + }, + "runtime_checks": { + "name": "runtime_checks", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "runtime_events": { + "name": "runtime_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instance_settings": { + "name": "instance_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "instance_settings_instance_id_unique": { + "name": "instance_settings_instance_id_unique", + "columns": [ + "instance_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_ai_settings": { + "name": "user_ai_settings", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'gemini'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_ai_settings_owner_user_id_user_id_fk": { + "name": "user_ai_settings_owner_user_id_user_id_fk", + "tableFrom": "user_ai_settings", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "user_ai_settings_provider_check": { + "name": "user_ai_settings_provider_check", + "value": "\"user_ai_settings\".\"provider\" in ('gemini', 'openai', 'groq', 'ollama')" + } + } + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Europe/Istanbul'" + }, + "default_currency": { + "name": "default_currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'TRY'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'tr'" + }, + "date_format": { + "name": "date_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dd.MM.yyyy'" + }, + "color_mode": { + "name": "color_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "sidebar_collapsed": { + "name": "sidebar_collapsed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_owner_user_id_user_id_fk": { + "name": "user_preferences_owner_user_id_user_id_fk", + "tableFrom": "user_preferences", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "user_preferences_currency_check": { + "name": "user_preferences_currency_check", + "value": "length(\"user_preferences\".\"default_currency\") = 3" + }, + "user_preferences_language_check": { + "name": "user_preferences_language_check", + "value": "\"user_preferences\".\"language\" in ('tr', 'en')" + }, + "user_preferences_color_mode_check": { + "name": "user_preferences_color_mode_check", + "value": "\"user_preferences\".\"color_mode\" in ('light', 'dark', 'system')" + } + } + }, + "files": { + "name": "files", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_user_id": { + "name": "uploaded_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_user_id": { + "name": "auth_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'private'" + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "files_storage_path_unique": { + "name": "files_storage_path_unique", + "columns": [ + "storage_path" + ], + "isUnique": true + }, + "files_owner_kind_idx": { + "name": "files_owner_kind_idx", + "columns": [ + "owner_user_id", + "kind" + ], + "isUnique": false + }, + "files_auth_user_id_idx": { + "name": "files_auth_user_id_idx", + "columns": [ + "auth_user_id" + ], + "isUnique": false + }, + "files_project_id_idx": { + "name": "files_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "files_sha256_idx": { + "name": "files_sha256_idx", + "columns": [ + "sha256" + ], + "isUnique": false + } + }, + "foreignKeys": { + "files_owner_user_id_user_id_fk": { + "name": "files_owner_user_id_user_id_fk", + "tableFrom": "files", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "files_uploaded_by_user_id_user_id_fk": { + "name": "files_uploaded_by_user_id_user_id_fk", + "tableFrom": "files", + "tableTo": "user", + "columnsFrom": [ + "uploaded_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "files_auth_user_id_user_id_fk": { + "name": "files_auth_user_id_user_id_fk", + "tableFrom": "files", + "tableTo": "user", + "columnsFrom": [ + "auth_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "files_project_id_projects_id_fk": { + "name": "files_project_id_projects_id_fk", + "tableFrom": "files", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "files_kind_check": { + "name": "files_kind_check", + "value": "\"files\".\"kind\" in ('avatar', 'branding_logo', 'branding_icon', 'project_asset')" + }, + "files_visibility_check": { + "name": "files_visibility_check", + "value": "\"files\".\"visibility\" in ('private', 'portal', 'public_branding')" + }, + "files_byte_size_check": { + "name": "files_byte_size_check", + "value": "\"files\".\"byte_size\" > 0" + }, + "files_sha256_check": { + "name": "files_sha256_check", + "value": "length(\"files\".\"sha256\") = 64" + }, + "files_storage_path_check": { + "name": "files_storage_path_check", + "value": "\"files\".\"storage_path\" not like '/%' and instr(\"files\".\"storage_path\", '..') = 0" + }, + "files_resource_check": { + "name": "files_resource_check", + "value": "(\n (\"files\".\"kind\" = 'avatar' and \"files\".\"auth_user_id\" is not null and \"files\".\"project_id\" is null and \"files\".\"visibility\" = 'private')\n or (\"files\".\"kind\" in ('branding_logo', 'branding_icon') and \"files\".\"auth_user_id\" is null and \"files\".\"project_id\" is null and \"files\".\"visibility\" = 'public_branding')\n or (\"files\".\"kind\" = 'project_asset' and \"files\".\"auth_user_id\" is null and \"files\".\"project_id\" is not null and \"files\".\"visibility\" in ('private', 'portal'))\n )" + } + } + }, + "instance_branding": { + "name": "instance_branding", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "application_name": { + "name": "application_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Neta'" + }, + "short_name": { + "name": "short_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Neta'" + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#C81E1E'" + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#E6EDF5'" + }, + "light_logo_file_id": { + "name": "light_logo_file_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dark_logo_file_id": { + "name": "dark_logo_file_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_file_id": { + "name": "icon_file_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_color_mode": { + "name": "default_color_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "radius_scale": { + "name": "radius_scale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "organization_name": { + "name": "organization_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "support_email": { + "name": "support_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "portal_welcome_text": { + "name": "portal_welcome_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "portal_footer_text": { + "name": "portal_footer_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "instance_branding_owner_unique": { + "name": "instance_branding_owner_unique", + "columns": [ + "owner_user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "instance_branding_owner_user_id_user_id_fk": { + "name": "instance_branding_owner_user_id_user_id_fk", + "tableFrom": "instance_branding", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "instance_branding_light_logo_file_id_files_id_fk": { + "name": "instance_branding_light_logo_file_id_files_id_fk", + "tableFrom": "instance_branding", + "tableTo": "files", + "columnsFrom": [ + "light_logo_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "instance_branding_dark_logo_file_id_files_id_fk": { + "name": "instance_branding_dark_logo_file_id_files_id_fk", + "tableFrom": "instance_branding", + "tableTo": "files", + "columnsFrom": [ + "dark_logo_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "instance_branding_icon_file_id_files_id_fk": { + "name": "instance_branding_icon_file_id_files_id_fk", + "tableFrom": "instance_branding", + "tableTo": "files", + "columnsFrom": [ + "icon_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "instance_branding_updated_by_user_id_user_id_fk": { + "name": "instance_branding_updated_by_user_id_user_id_fk", + "tableFrom": "instance_branding", + "tableTo": "user", + "columnsFrom": [ + "updated_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "instance_branding_id_check": { + "name": "instance_branding_id_check", + "value": "\"instance_branding\".\"id\" = 'default'" + }, + "instance_branding_primary_color_check": { + "name": "instance_branding_primary_color_check", + "value": "\"instance_branding\".\"primary_color\" glob '#[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]'" + }, + "instance_branding_accent_color_check": { + "name": "instance_branding_accent_color_check", + "value": "\"instance_branding\".\"accent_color\" glob '#[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]'" + }, + "instance_branding_color_mode_check": { + "name": "instance_branding_color_mode_check", + "value": "\"instance_branding\".\"default_color_mode\" in ('light', 'dark', 'system')" + }, + "instance_branding_radius_scale_check": { + "name": "instance_branding_radius_scale_check", + "value": "\"instance_branding\".\"radius_scale\" in ('compact', 'default', 'soft')" + } + } + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/server/db/migrations/meta/_journal.json b/server/db/migrations/meta/_journal.json index 0941571..47f09e5 100644 --- a/server/db/migrations/meta/_journal.json +++ b/server/db/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1784266217938, "tag": "0006_moaning_kitty_pryde", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1784268984532, + "tag": "0007_flaky_kinsey_walden", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/db/schema/settings.ts b/server/db/schema/settings.ts index befee90..1b93398 100644 --- a/server/db/schema/settings.ts +++ b/server/db/schema/settings.ts @@ -4,6 +4,13 @@ import { user } from "./auth"; export type AiProvider = "gemini" | "openai" | "groq" | "ollama"; +export const instanceSettings = sqliteTable("instance_settings", { + key: text("key").primaryKey(), + instanceId: text("instance_id").notNull().unique(), + createdAt: text("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(), + updatedAt: text("updated_at").default(sql`CURRENT_TIMESTAMP`).notNull(), +}); + export const userAiSettings = sqliteTable( "user_ai_settings", { diff --git a/server/domain/errors.ts b/server/domain/errors.ts index d82edb4..e3f1164 100644 --- a/server/domain/errors.ts +++ b/server/domain/errors.ts @@ -6,7 +6,8 @@ export type DomainErrorCode = | "CONFLICT" | "INVARIANT_VIOLATION" | "UPSTREAM_ERROR" - | "UPSTREAM_TIMEOUT"; + | "UPSTREAM_TIMEOUT" + | "SERVICE_UNAVAILABLE"; const statusByCode: Record = { VALIDATION_ERROR: 400, @@ -17,6 +18,7 @@ const statusByCode: Record = { INVARIANT_VIOLATION: 422, UPSTREAM_ERROR: 502, UPSTREAM_TIMEOUT: 504, + SERVICE_UNAVAILABLE: 503, }; export class DomainError extends Error { diff --git a/server/instance/runtime.ts b/server/instance/runtime.ts new file mode 100644 index 0000000..54212d5 --- /dev/null +++ b/server/instance/runtime.ts @@ -0,0 +1,8 @@ +import "server-only"; + +import { getSqliteConnection } from "../db/client"; +import { InstanceService } from "./service"; + +export function getInstanceService(): InstanceService { + return new InstanceService(getSqliteConnection().db); +} diff --git a/server/instance/service.ts b/server/instance/service.ts new file mode 100644 index 0000000..73b32ee --- /dev/null +++ b/server/instance/service.ts @@ -0,0 +1,54 @@ +import { randomUUID } from "node:crypto"; +import type { DomainDatabase } from "../domain/database"; +import { DomainError } from "../domain/errors"; +import { createInstanceRepository } from "../repositories/instance"; + +export type InstanceIdentity = { + instanceId: string; + createdAt: string; +}; + +export class InstanceService { + private readonly repository; + + constructor(private readonly db: DomainDatabase) { + this.repository = createInstanceRepository(db); + } + + getIdentity(): InstanceIdentity { + const existing = this.repository.get(); + if (existing) return toIdentity(existing); + + this.repository.createIfMissing(randomUUID()); + const created = this.repository.get(); + if (!created) { + throw new DomainError( + "INVARIANT_VIOLATION", + "Instance kimliği oluşturulamadı.", + ); + } + return toIdentity(created); + } +} + +function toIdentity(value: { + instanceId: string; + createdAt: string; +}): InstanceIdentity { + return { + instanceId: value.instanceId, + createdAt: sqliteTimestampToIso(value.createdAt), + }; +} + +function sqliteTimestampToIso(value: string): string { + const normalized = value.includes("T") ? value : `${value.replace(" ", "T")}Z`; + const timestamp = new Date(normalized); + if (Number.isNaN(timestamp.getTime())) { + throw new DomainError( + "INVARIANT_VIOLATION", + "Instance oluşturulma zamanı geçersiz.", + ); + } + return timestamp.toISOString(); +} diff --git a/server/repositories/instance.ts b/server/repositories/instance.ts new file mode 100644 index 0000000..bc017c0 --- /dev/null +++ b/server/repositories/instance.ts @@ -0,0 +1,22 @@ +import { eq } from "drizzle-orm"; +import { instanceSettings } from "../db/schema"; +import type { DomainDatabase } from "../domain/database"; + +const INSTANCE_SETTINGS_KEY = "default"; + +export function createInstanceRepository(db: DomainDatabase) { + return { + get: () => + db + .select() + .from(instanceSettings) + .where(eq(instanceSettings.key, INSTANCE_SETTINGS_KEY)) + .get(), + createIfMissing: (instanceId: string) => + db + .insert(instanceSettings) + .values({ key: INSTANCE_SETTINGS_KEY, instanceId }) + .onConflictDoNothing({ target: instanceSettings.key }) + .run(), + }; +}