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:
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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[],
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
}));
|
})),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user