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
+2 -1
View File
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { listJobFiles } from "@/lib/appwrite/job-file-queries"; import { listJobFiles } from "@/lib/appwrite/job-file-queries";
import { listJobHistory } from "@/lib/appwrite/job-history-queries"; import { listJobHistory } from "@/lib/appwrite/job-history-queries";
import { toPlain } from "@/lib/appwrite/serialize";
import { import {
JOB_STATUS_LABELS, JOB_STATUS_LABELS,
JOB_STEP_LABELS, JOB_STEP_LABELS,
@@ -57,7 +58,7 @@ export default async function JobDetailPage({
let job: Job; let job: Job;
try { try {
const row = await tablesDB.getRow(DATABASE_ID, TABLES.jobs, jobId); const row = await tablesDB.getRow(DATABASE_ID, TABLES.jobs, jobId);
job = row as unknown as Job; job = toPlain(row as unknown as Job);
} catch { } catch {
notFound(); notFound();
} }
+5 -2
View File
@@ -10,6 +10,7 @@ import {
type TenantSettings, type TenantSettings,
} from "./schema"; } from "./schema";
import { createAdminClient } from "./server"; import { createAdminClient } from "./server";
import { toPlain } from "./serialize";
export type CounterpartTenant = { export type CounterpartTenant = {
tenantId: string; tenantId: string;
@@ -54,10 +55,12 @@ async function enrich(
new Set(rows.map((r) => counterpartTenantId(r, selfTenantId))), new Set(rows.map((r) => counterpartTenantId(r, selfTenantId))),
); );
const map = await fetchCounterparts(counterpartIds); const map = await fetchCounterparts(counterpartIds);
return rows.map((r) => ({ return toPlain(
rows.map((r) => ({
...r, ...r,
counterpart: map.get(counterpartTenantId(r, selfTenantId)) ?? null, counterpart: map.get(counterpartTenantId(r, selfTenantId)) ?? null,
})); })),
);
} }
async function listConnectionsByStatus( async function listConnectionsByStatus(
+3 -2
View File
@@ -12,6 +12,7 @@ import {
type TenantSettings, type TenantSettings,
} from "./schema"; } from "./schema";
import { createAdminClient } from "./server"; import { createAdminClient } from "./server";
import { toPlain } from "./serialize";
export type DashboardJob = Job & { export type DashboardJob = Job & {
counterpartName: string | null; counterpartName: string | null;
@@ -140,7 +141,7 @@ export async function getDashboardData(
if (e.type === "payable") payablePending += e.amount; if (e.type === "payable") payablePending += e.amount;
} }
return { return toPlain({
openJobsCount: openJobsRes.total, openJobsCount: openJobsRes.total,
pendingActionCount: pendingActionRes.total, pendingActionCount: pendingActionRes.total,
unreadCount: unreadRes.total, unreadCount: unreadRes.total,
@@ -154,5 +155,5 @@ export async function getDashboardData(
counterpartMap.get(isLab ? j.clinicTenantId : j.labTenantId) ?? null, counterpartMap.get(isLab ? j.clinicTenantId : j.labTenantId) ?? null,
})), })),
recentNotifications: notifRes.rows as unknown as Notification[], recentNotifications: notifRes.rows as unknown as Notification[],
}; });
} }
+6 -3
View File
@@ -10,6 +10,7 @@ import {
type TenantSettings, type TenantSettings,
} from "./schema"; } from "./schema";
import { createAdminClient } from "./server"; import { createAdminClient } from "./server";
import { toPlain } from "./serialize";
export type FinanceCounterpart = { export type FinanceCounterpart = {
tenantId: string; tenantId: string;
@@ -40,7 +41,7 @@ export async function listFinanceEntries(
new Set(rows.map((r) => r.counterpartTenantId).filter((v): v is string => Boolean(v))), new Set(rows.map((r) => r.counterpartTenantId).filter((v): v is string => Boolean(v))),
); );
if (counterpartIds.length === 0) { if (counterpartIds.length === 0) {
return rows.map((r) => ({ ...r, counterpart: null })); return toPlain(rows.map((r) => ({ ...r, counterpart: null })));
} }
const counterpartsRes = await tablesDB.listRows({ const counterpartsRes = await tablesDB.listRows({
@@ -57,10 +58,12 @@ export async function listFinanceEntries(
}); });
} }
return rows.map((r) => ({ return toPlain(
rows.map((r) => ({
...r, ...r,
counterpart: r.counterpartTenantId ? map.get(r.counterpartTenantId) ?? null : null, counterpart: r.counterpartTenantId ? map.get(r.counterpartTenantId) ?? null : null,
})); })),
);
} }
export function summarizeFinance( export function summarizeFinance(
+5 -2
View File
@@ -4,6 +4,7 @@ import { Query } from "node-appwrite";
import { BUCKETS, DATABASE_ID, TABLES, type JobFile } from "./schema"; import { BUCKETS, DATABASE_ID, TABLES, type JobFile } from "./schema";
import { createAdminClient } from "./server"; import { createAdminClient } from "./server";
import { toPlain } from "./serialize";
import { getFileViewUrl } from "./storage"; import { getFileViewUrl } from "./storage";
export type JobFileWithUrl = JobFile & { export type JobFileWithUrl = JobFile & {
@@ -22,8 +23,10 @@ export async function listJobFiles(jobId: string): Promise<JobFileWithUrl[]> {
], ],
}); });
const rows = result.rows as unknown as JobFile[]; const rows = result.rows as unknown as JobFile[];
return rows.map((r) => ({ return toPlain(
rows.map((r) => ({
...r, ...r,
url: getFileViewUrl(BUCKETS.jobFiles, r.fileId), 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 { DATABASE_ID, TABLES, type JobStatusHistory } from "./schema";
import { createAdminClient } from "./server"; import { createAdminClient } from "./server";
import { toPlain } from "./serialize";
export async function listJobHistory(jobId: string): Promise<JobStatusHistory[]> { export async function listJobHistory(jobId: string): Promise<JobStatusHistory[]> {
const { tablesDB } = createAdminClient(); const { tablesDB } = createAdminClient();
@@ -16,5 +17,5 @@ export async function listJobHistory(jobId: string): Promise<JobStatusHistory[]>
Query.limit(100), Query.limit(100),
], ],
}); });
return result.rows as unknown as JobStatusHistory[]; return toPlain(result.rows as unknown as JobStatusHistory[]);
} }
+7 -4
View File
@@ -10,6 +10,7 @@ import {
type TenantSettings, type TenantSettings,
} from "./schema"; } from "./schema";
import { createAdminClient } from "./server"; import { createAdminClient } from "./server";
import { toPlain } from "./serialize";
export type JobCounterpart = { export type JobCounterpart = {
tenantId: string; tenantId: string;
@@ -58,7 +59,7 @@ export async function listInboundJobs(labTenantId: string): Promise<JobWithCount
}); });
const jobs = result.rows as unknown as Job[]; const jobs = result.rows as unknown as Job[];
const map = await fetchTenants(Array.from(new Set(jobs.map((j) => j.clinicTenantId)))); 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. */ /** 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 jobs = result.rows as unknown as Job[];
const map = await fetchTenants(Array.from(new Set(jobs.map((j) => j.labTenantId)))); 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. */ /** 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, (r) => r.labTenantId,
); );
const map = await fetchTenants(labIds); const map = await fetchTenants(labIds);
return labIds return toPlain(
labIds
.map((id) => map.get(id)) .map((id) => map.get(id))
.filter((v): v is JobCounterpart => Boolean(v)); .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 { DATABASE_ID, TABLES, type Notification } from "./schema";
import { createAdminClient } from "./server"; import { createAdminClient } from "./server";
import { toPlain } from "./serialize";
type CreateNotificationInput = { type CreateNotificationInput = {
tenantId: string; tenantId: string;
@@ -76,5 +77,5 @@ export async function listNotifications(
Query.limit(limit), 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 { DATABASE_ID, TABLES, type Prosthetic } from "./schema";
import { createAdminClient } from "./server"; import { createAdminClient } from "./server";
import { toPlain } from "./serialize";
export async function listProsthetics(tenantId: string): Promise<Prosthetic[]> { export async function listProsthetics(tenantId: string): Promise<Prosthetic[]> {
const { tablesDB } = createAdminClient(); const { tablesDB } = createAdminClient();
@@ -16,7 +17,7 @@ export async function listProsthetics(tenantId: string): Promise<Prosthetic[]> {
Query.limit(200), 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[]> { export async function listActiveProsthetics(tenantId: string): Promise<Prosthetic[]> {
@@ -31,5 +32,5 @@ export async function listActiveProsthetics(tenantId: string): Promise<Prostheti
Query.limit(200), 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;
}