fix: serialize Appwrite rows before sending to client components

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.
This commit is contained in:
kovakmedya
2026-05-21 20:57:59 +03:00
parent c980ce1d8d
commit f34630de62
10 changed files with 57 additions and 25 deletions
+7 -4
View File
@@ -10,6 +10,7 @@ import {
type TenantSettings,
} from "./schema";
import { createAdminClient } from "./server";
import { toPlain } from "./serialize";
export type CounterpartTenant = {
tenantId: string;
@@ -54,10 +55,12 @@ async function enrich(
new Set(rows.map((r) => counterpartTenantId(r, selfTenantId))),
);
const map = await fetchCounterparts(counterpartIds);
return rows.map((r) => ({
...r,
counterpart: map.get(counterpartTenantId(r, selfTenantId)) ?? null,
}));
return toPlain(
rows.map((r) => ({
...r,
counterpart: map.get(counterpartTenantId(r, selfTenantId)) ?? null,
})),
);
}
async function listConnectionsByStatus(
+3 -2
View File
@@ -12,6 +12,7 @@ import {
type TenantSettings,
} from "./schema";
import { createAdminClient } from "./server";
import { toPlain } from "./serialize";
export type DashboardJob = Job & {
counterpartName: string | null;
@@ -140,7 +141,7 @@ export async function getDashboardData(
if (e.type === "payable") payablePending += e.amount;
}
return {
return toPlain({
openJobsCount: openJobsRes.total,
pendingActionCount: pendingActionRes.total,
unreadCount: unreadRes.total,
@@ -154,5 +155,5 @@ export async function getDashboardData(
counterpartMap.get(isLab ? j.clinicTenantId : j.labTenantId) ?? null,
})),
recentNotifications: notifRes.rows as unknown as Notification[],
};
});
}
+8 -5
View File
@@ -10,6 +10,7 @@ import {
type TenantSettings,
} from "./schema";
import { createAdminClient } from "./server";
import { toPlain } from "./serialize";
export type FinanceCounterpart = {
tenantId: string;
@@ -40,7 +41,7 @@ export async function listFinanceEntries(
new Set(rows.map((r) => r.counterpartTenantId).filter((v): v is string => Boolean(v))),
);
if (counterpartIds.length === 0) {
return rows.map((r) => ({ ...r, counterpart: null }));
return toPlain(rows.map((r) => ({ ...r, counterpart: null })));
}
const counterpartsRes = await tablesDB.listRows({
@@ -57,10 +58,12 @@ export async function listFinanceEntries(
});
}
return rows.map((r) => ({
...r,
counterpart: r.counterpartTenantId ? map.get(r.counterpartTenantId) ?? null : null,
}));
return toPlain(
rows.map((r) => ({
...r,
counterpart: r.counterpartTenantId ? map.get(r.counterpartTenantId) ?? null : null,
})),
);
}
export function summarizeFinance(
+7 -4
View File
@@ -4,6 +4,7 @@ import { Query } from "node-appwrite";
import { BUCKETS, DATABASE_ID, TABLES, type JobFile } from "./schema";
import { createAdminClient } from "./server";
import { toPlain } from "./serialize";
import { getFileViewUrl } from "./storage";
export type JobFileWithUrl = JobFile & {
@@ -22,8 +23,10 @@ export async function listJobFiles(jobId: string): Promise<JobFileWithUrl[]> {
],
});
const rows = result.rows as unknown as JobFile[];
return rows.map((r) => ({
...r,
url: getFileViewUrl(BUCKETS.jobFiles, r.fileId),
}));
return toPlain(
rows.map((r) => ({
...r,
url: getFileViewUrl(BUCKETS.jobFiles, r.fileId),
})),
);
}
+2 -1
View File
@@ -4,6 +4,7 @@ import { Query } from "node-appwrite";
import { DATABASE_ID, TABLES, type JobStatusHistory } from "./schema";
import { createAdminClient } from "./server";
import { toPlain } from "./serialize";
export async function listJobHistory(jobId: string): Promise<JobStatusHistory[]> {
const { tablesDB } = createAdminClient();
@@ -16,5 +17,5 @@ export async function listJobHistory(jobId: string): Promise<JobStatusHistory[]>
Query.limit(100),
],
});
return result.rows as unknown as JobStatusHistory[];
return toPlain(result.rows as unknown as JobStatusHistory[]);
}
+8 -5
View File
@@ -10,6 +10,7 @@ import {
type TenantSettings,
} from "./schema";
import { createAdminClient } from "./server";
import { toPlain } from "./serialize";
export type JobCounterpart = {
tenantId: string;
@@ -58,7 +59,7 @@ export async function listInboundJobs(labTenantId: string): Promise<JobWithCount
});
const jobs = result.rows as unknown as Job[];
const map = await fetchTenants(Array.from(new Set(jobs.map((j) => j.clinicTenantId))));
return jobs.map((j) => enrichJob(j, j.clinicTenantId, map));
return toPlain(jobs.map((j) => enrichJob(j, j.clinicTenantId, map)));
}
/** Outbound for a clinic tenant — jobs the clinic has sent. */
@@ -75,7 +76,7 @@ export async function listOutboundJobs(clinicTenantId: string): Promise<JobWithC
});
const jobs = result.rows as unknown as Job[];
const map = await fetchTenants(Array.from(new Set(jobs.map((j) => j.labTenantId))));
return jobs.map((j) => enrichJob(j, j.labTenantId, map));
return toPlain(jobs.map((j) => enrichJob(j, j.labTenantId, map)));
}
/** For clinic's "Yeni İş Yayınla" form: list approved labs they can send to. */
@@ -96,7 +97,9 @@ export async function listApprovedLabsForClinic(
(r) => r.labTenantId,
);
const map = await fetchTenants(labIds);
return labIds
.map((id) => map.get(id))
.filter((v): v is JobCounterpart => Boolean(v));
return toPlain(
labIds
.map((id) => map.get(id))
.filter((v): v is JobCounterpart => Boolean(v)),
);
}
+2 -1
View File
@@ -4,6 +4,7 @@ import { ID, Permission, Query, Role } from "node-appwrite";
import { DATABASE_ID, TABLES, type Notification } from "./schema";
import { createAdminClient } from "./server";
import { toPlain } from "./serialize";
type CreateNotificationInput = {
tenantId: string;
@@ -76,5 +77,5 @@ export async function listNotifications(
Query.limit(limit),
],
});
return result.rows as unknown as Notification[];
return toPlain(result.rows as unknown as Notification[]);
}
+3 -2
View File
@@ -4,6 +4,7 @@ import { Query } from "node-appwrite";
import { DATABASE_ID, TABLES, type Prosthetic } from "./schema";
import { createAdminClient } from "./server";
import { toPlain } from "./serialize";
export async function listProsthetics(tenantId: string): Promise<Prosthetic[]> {
const { tablesDB } = createAdminClient();
@@ -16,7 +17,7 @@ export async function listProsthetics(tenantId: string): Promise<Prosthetic[]> {
Query.limit(200),
],
});
return result.rows as unknown as Prosthetic[];
return toPlain(result.rows as unknown as Prosthetic[]);
}
export async function listActiveProsthetics(tenantId: string): Promise<Prosthetic[]> {
@@ -31,5 +32,5 @@ export async function listActiveProsthetics(tenantId: string): Promise<Prostheti
Query.limit(200),
],
});
return result.rows as unknown as Prosthetic[];
return toPlain(result.rows as unknown as Prosthetic[]);
}
+15
View File
@@ -0,0 +1,15 @@
import "server-only";
/**
* Appwrite SDK returns row objects that carry a non-plain prototype with
* helpers like `toString`. Next.js's server-to-client serializer rejects
* those. Use this helper to flatten any row (or row array) into a plain
* object before crossing the boundary or returning from a server fn.
*
* JSON-roundtripping is deliberate: cheap, deterministic, drops every
* non-serialisable field. Anything that needs to survive (Dates, Buffers)
* has to be converted explicitly by the caller anyway.
*/
export function toPlain<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}