The shadcn template ships SelectTrigger with 'w-fit' so the control sizes
to its content. That works for headless toolbar pickers but in our forms
the Select sits in a grid column next to Inputs of the same row — the
'w-fit' behaviour made labs/patients/products dropdowns visibly narrower
than their neighbouring fields. Switched the trigger default to 'w-full'
so column layouts stay tidy; anything that genuinely wants content-width
can still override via className.
Also fixed SelectContent: the Radix portal was capped at 'min-w-[8rem]'
which can be narrower than the trigger and the popper would dock to that
fixed 128px rather than the field. Replaced with
'min-w-[var(--radix-select-trigger-width)]' so the dropdown is always at
least as wide as the field it dropped from.
What changed
- jobs.teeth (FDI string[]). memberCount becomes a derived field (teeth.length).
A new TeethChart component renders the full permanent dentition as a
16-column grid for each arch with click-toggle selection.
- /jobs/new: removed the price + currency inputs and the manual memberCount
field. Clinics now pick teeth via the chart; the form blocks submission
until at least one tooth is selected.
- createJobAction calls a new calculateJobPrice() helper that walks the
pricing cascade and writes price + currency on the job server-side. A
clinic-supplied price hidden field would now be ignored — the field
isn't even in the schema.
Pricing cascade (calculateJobPrice, lib/appwrite/pricing.ts)
1. clinic_pricing row matching (lab, clinic, type) with customUnitPrice
→ use that flat unit price.
2. clinic_pricing row with discountPercent → catalog unitPrice × (1-d).
3. lab's prosthetics catalog row matching type (not archived).
4. nothing → price stays null; lab can still set it manually later.
Clinic-specific overrides (clinic_pricing table)
- Unique on (labTenantId, clinicTenantId, prostheticType) so each
combination has at most one rule.
- Row permissions: read by both teams (transparency for clinic), write
only by lab — clinic can see the discount they're getting but cannot
edit it.
- setClinicPricingAction validates an approved connection exists before
creating/updating, and rejects requests where neither customUnitPrice
nor discountPercent is set.
- clearClinicPricingAction wipes a rule (catalog price re-applies).
UI
- /connections 'Bağlantılarım' table gets a new column showing the active
pricing rules per counterpart. Lab side has a 'Fiyatlandırma' button
that opens a dialog (PROSTHETIC_TYPE × customPrice|discountPercent form
+ list of active rules with delete). Clinic side is read-only.
- Job detail: 'Fiyat' field now shows 'Lab tarafından belirlenecek' when
null, instead of a literal —. Adds a 'Dişler' info block listing the
selected FDI numbers.
Clinics get a real patient ledger. Labs see only patientCode — no name,
phone, date of birth, or notes ever cross the team boundary.
Data model
- New table 'patients' (clinicTenantId, patientCode, firstName, lastName,
phone?, dateOfBirth?, notes?, archived). Unique index on
(clinicTenantId, patientCode) so each clinic gets its own code space.
Fulltext index on (firstName, lastName) for future patient search.
Row permissions Role.team(clinicTenantId) only — labs literally cannot
read the rows.
- jobs.patientId attribute (optional) + key index, references the
patient row when one exists. patientCode stays denormalised on jobs so
labs keep a stable identifier without joining patients.
Server
- createPatientAction: clinic-only, requireTenantKind guard. Protocol no
is optional; if absent we generate a 6-char unique code (re-roll on
collision, 8 attempts). Duplicate protocol no within a clinic is
rejected with a friendly error.
- updatePatientAction: edits name/phone/dob/notes. patientCode is
explicitly NOT mutable — re-keying historical jobs would be confusing.
- archivePatientAction: toggle, preserves history.
- listPatients / getPatient queries return plain objects via toPlain.
UI
- /patients page (clinic-only, sidebar nav 'Hastalar', middleware
protected): table + add form + edit dialog + archive.
- /jobs/new: patient Select replaces the bare patientCode input. Picking
a patient locks the patientCode field to that patient's code; falling
back to 'Hasta listesinde yok — kodu manuel gir' keeps the old free-
text flow.
- createJobAction validates patientId ownership and overwrites
patientCode with the patient's code on the server, so a manipulated
form can't desync the two.
- /jobs/[jobId] (clinic side only): adds a 'Hasta Bilgileri' card with
name/phone/dob/notes and uses the patient's full name as the page
title. Lab side is unchanged — code only.
The protocol-no / generated-code split matches what the user asked for:
existing patient management software's protocol number flows in directly,
otherwise the system mints one.
XHR's upload.progress event only tracks browser→server byte transfer. It
fires 100% the moment the multipart body has been pushed, but at that
point our route handler hasn't even started streaming the files into
Appwrite Storage yet. A 200MB upload completes the network phase fast,
then sits 30-60 seconds while the server does sequential storage.createFile
+ tablesDB.createRow per file. The UI was stuck on '100%' the whole time,
making it look frozen.
Switched the form to a discriminated phase state:
idle → uploading (real bytes %) → processing (full bar + spinner)
→ idle (success/fail)
Listening on xhr.upload's own load event flips us to 'processing' the
instant the body is done. The outer xhr.load fires when the route handler
responds with JSON — that's when we toast + router.refresh().
User now sees: bar fills, then 'Sunucu Appwrite'a yazıyor — büyük dosyalar
30-60 sn sürebilir' message until the response comes back. No more
mystery wait.
Memory [[feedback_use_server_only_async]]: 'use server' files can only
export async functions. notification-actions.ts also exported the
NotificationActionState type and an initialNotificationActionState const,
which Next 16 now flags hard at runtime ('A "use server" file can only
export async functions, found object.').
Moved both to a sibling notification-types.ts (same pattern we already
follow for connection-types / job-file-types / prosthetic-types). The
client component imports the const from -types and the actions from
-actions; no behaviour change.
Cluster: Appwrite container _APP_STORAGE_LIMIT 30000000 → 209715200
(200MB) in /root/services/appwrite/.env on kovaksoft-coolify, then
docker compose up -d to roll the worker pool with the new value.
Backup of the .env left at .env.bak.<date>.
Bucket: job-files maximumFileSize updated to 209715200 via Appwrite MCP
(storage_update_bucket).
App: MAX_FILE_BYTES in both the upload API route and the original server
action raised to 200MB. Client-side panel guard relaxed accordingly —
one large file is now allowed to fill the entire batch (the 200MB
proxy/serverActions cap is the bottleneck, not the per-file rule).
Error copy updated.
isletmem and any other tenants on the cluster also get the new limit,
which is the desired behaviour — old 30MB ceiling was a relic of an
Appwrite default that no DLS workflow can actually live with.
- next.config: serverActions.bodySizeLimit + experimental.proxyClientMaxBodySize
bumped from 500mb back down to 200mb. Batch ceiling (client side) is 180mb
to stay comfortably under the proxy cap.
- New POST /api/jobs/[jobId]/files endpoint replaces the server action for
upload. Same auth/permissions/rollback semantics, but Returns JSON so the
client can read the response. Server action is retained for delete only.
- JobFilesPanel switched from useActionState to XMLHttpRequest.upload —
xhr.upload.onprogress feeds a Progress bar (real bytes, not a fake
ticker). Cancel button aborts the in-flight request. Successful upload
triggers router.refresh() to repopulate the file list.
Server actions can't expose upload progress (no streaming feedback in the
RSC protocol yet), so any progress UX needs to go through fetch/XHR
against a route handler. Trade-off accepted.
The previous attempt put 'middlewareClientMaxBodySize' at the top level of
NextConfig — Next 16.1 rejected it as an unrecognised key. Inspecting the
shipped config schema (node_modules/next/dist/server/config-schema.js)
revealed the option lives under experimental and was renamed to
'proxyClientMaxBodySize' when middleware.ts → proxy.ts; the old name is
still accepted but deprecated. Switched to the new name and confirmed Next
now lists it in the Experiments banner at boot.
While we were at it the cap was bumped to 500MB (was 100MB) so batch
uploads have headroom over the 30MB-per-file bucket limit. Added a
client-side pre-flight in JobFilesPanel: rejects individual files >30MB
and total batches >400MB *before* hitting the server, with inline error
messaging instead of a cryptic 'Unexpected end of form' bounce.
Also raised serverActions.bodySizeLimit to 500mb to match.
node-appwrite's storage.createFile happily takes the Web File API today,
but Next.js's multipart parser had already consumed the request body by
the time the SDK tries to stream it again — the SDK's second pass dies
with 'Unexpected end of form'. isletmem-kovakcrm's logo-actions uses the
documented pattern: arrayBuffer → Buffer → InputFile.fromBuffer(buf,
name). Adopting the same approach in uploadJobFilesAction.
The middlewareClientMaxBodySize bump from the previous commit still
matters (lifts the 10MB cap so the body even reaches us), but on its own
it wasn't enough: the Web File handoff itself was broken.
InputFile is exported from 'node-appwrite/file' (separate entry point —
the helper isn't on the main package export).
Next 16 caps any request body that flows through middleware at 10MB by
default. Our auth middleware matches every path, so /jobs/:id POSTs from
the file upload form hit 'Request body exceeded 10MB / Unexpected end of
form' the moment a user picked anything bigger than ~10MB total — the
server action never even ran.
serverActions.bodySizeLimit alone isn't enough; the new
middlewareClientMaxBodySize knob (Next 16) is the one that gates
middleware-handled bodies. Set both to 100mb so the 30MB-per-file bucket
limit is what actually matters.
The key isn't in NextConfig's TS types yet (Next 16.1), so it's assigned
via a narrow cast on the side rather than dropped into the object literal.
Also added console.log/error breadcrumbs to uploadJobFilesAction so the
next mystery upload failure shows up in the dev server log immediately
instead of silently bouncing back as 'Bağlantı hatası'.
Next 16's server-to-client serializer rejects values whose prototype is
not plain Object. node-appwrite returns row objects carrying internal
helpers (toString etc.), so every <ClientComponent prop={row}> crashed with
'Only plain objects, and a few built-ins, can be passed to Client
Components from Server Components.'
Added a tiny toPlain helper that JSON-roundtrips any value and applied it
at the boundary of every query that returns rows consumed by 'use client'
files:
- connection-queries (enrich)
- job-queries (inbound, outbound, approved labs)
- job-file-queries (listJobFiles)
- job-history-queries (listJobHistory)
- prosthetic-queries (listProsthetics, listActiveProsthetics)
- finance-queries (listFinanceEntries)
- notification-helpers (listNotifications)
- dashboard-queries (getDashboardData)
- jobs/[jobId] page (direct getRow for the job prop on JobActionsPanel)
Internal Maps inside queries stay live — only the data crossing the
server/client boundary is normalised.
- getDashboardData aggregates open jobs, pending-action jobs, unread
notifications, pending finance totals, approved connection count, recent
jobs (up to 8) and recent notifications (up to 5) — single Promise.all so
the dashboard renders in one round-trip.
- Four stat cards, each a Link to the relevant module; tone (positive /
negative) flips between clinic (payable) and lab (receivable).
- Clinic users with zero approved connections see a 'Bağlantı Kur' prompt
card so they don't get stuck on /jobs/new.
- Recent jobs table is role-aware: lab sees Klinik column + 'Son Gelen
İşler' header, clinic sees Laboratuvar column + 'Son Giden İşler' header.
- Recent notifications panel with read/unread dot, clickable header arrow
to /notifications.
- ActiveContext now carries 'kind' (mirror of TenantSettings.kind) so we no
longer reach into ctx.settings?.kind in callers.
Step-by-step instructions for the production deploy that has to happen in
the admin.kovaksoft.com UI (Coolify doesn't expose a clean CLI for creating
resources). Covers DNS A record, Coolify resource setup, ENV mapping,
domain + SSL, Gitea webhook for auto-deploy, and a verification cookbook.
Troubleshooting section includes the Node 26 / node-appwrite patch already
shipped in repo.
Previously, signing in with a kind toggle when the account had no
tenant_settings row in the lab database returned 'Bu hesap için kayıt
bulunamadı'. For a freshly-imported isletmem user that has never opened
DLS, the right behaviour is onboarding — not a dead end.
resolveTenantOnLogin now distinguishes three states:
- no_tenants → redirect /onboarding?kind=<pill> (session stays)
- mismatch → real error naming the existing kind, session rolled back
- matched → existing tenant set as active, /dashboard
Onboarding page accepts ?kind= and the workspace form pre-selects it, so
the user keeps their login pill choice without re-picking. Also fixed the
'teams.total > 0' redirect — it now requires a tenant_settings row before
sending users off to /dashboard, otherwise users with cross-app teams but
no DLS workspace would bounce.
node-appwrite 23.1.0 ships a bundled undici Agent via node-fetch-native-with-agent.
That bundle uses an older undici dispatcher API that crashes on Node 26 with
'invalid onError method' (UND_ERR_INVALID_ARG), making every Appwrite call
fail with 'fetch failed' / our user-facing 'Bağlantı hatası' fallback.
The patch replaces createAgent/createFetch with thin pass-throughs to
globalThis.fetch — Node native fetch handles HTTPS to db.kovaksoft.com
directly, no proxy/agent customization needed. Verified end-to-end via
users.listMemberships against the live project.
Also added dev-mode error surfacing in appwriteError so future SDK
exceptions show the real message instead of 'Bağlantı hatası'.
These DLS module routes were added in the previous bootstrap but the auth
middleware's PROTECTED_PREFIXES list still mirrored isletmem's CRM modules,
so /jobs/inbound etc. were returning 200 without a session and exposing the
placeholder shell. Build smoke test caught it; layout-level redirect alone
was not enforcing it for those paths.
Sign-in form now has a Klinik / Laboratuvar pill toggle (defaults to clinic).
The selected kind is posted alongside email+password; the server action
resolves the user's memberships, picks a tenant_settings row matching the
chosen kind, and sets it as active. If no matching tenant exists the session
is rolled back with a clear error.
Invite-flow logins skip the kind check — invite code drives team assignment.