Adding Yodeck Sync to PCView: Extending the Inventory with a New Category
TL;DR: I added a full‑stack Yodeck inventory pipeline to the PCView monorepo, exposing API routes, a new Prisma model, and a dashboard page. The change lets us pull the “approved” Yodeck category from Google Sheets and keep the UI in sync with the database.
The Problem
Our inventory dashboard tracks cameras, office phones, guest phones, and a handful of other assets. The latest client request was to include Yodeck digital‑signage players (Raspberry Pi units) as a new inventory category.
The immediate blocker was two‑fold:
-
No schema –
prisma/schema.prismahad no model for Yodeck devices, so the ORM rejected any attempt to query or persist them. - Missing UI & API – The dashboard’s settings page only rendered sync buttons for existing categories, and there were no Next.js API routes to fetch or sync Yodeck data.
When I tried to add a quick endpoint that returned a hard‑coded list of Yodeck devices, the request failed with a 404 because the route didn’t exist, and the Prisma client threw Model "Yodeck" is not defined in your schema.prisma.
What I Tried First
My first attempt was a throw‑away mock:
// src/app/api/yodeck/devices/route.ts (initial version)
export async function GET() {
return NextResponse.json({
devices: [{ id: "dev-001", name: "Lobby Screen", status: "online" }],
});
}
I added the route, hit /api/yodeck/devices, and the JSON came back. However, this approach had two fatal flaws:
- No persistence – The data never hit the database, so any subsequent sync would overwrite it.
- No type safety – I was returning an ad‑hoc object that didn’t match any TypeScript interface, leading to mismatched props downstream.
I also tried to extend the existing GenericSyncButton component by passing a custom endpoint, but the component only accepted a hard‑coded sheetTabName prop, which meant the Google Sheets integration would never know which tab to read for Yodeck.
Both attempts proved unsustainable for a production‑grade feature.
The Implementation
1. Extend the configuration
pcview.config.ts now knows about the Yodeck Google Sheet tab:
// pcview.config.ts
export interface PcViewConfig {
cameras: { gid: string };
guestPhones: { gid: string };
officePhones: { gid: string };
+ yodeck: { gid: string }; // <- new entry
alerts: {
// ...
};
}
The gid is the Google Sheet tab identifier that the YodeckGoogleSheetsProvider will read.
2. Add a Prisma model
The biggest change lives in prisma/schema.prisma. I introduced a Yodeck model that mirrors the shape of the Google Sheet rows:
// prisma/schema.prisma
model Yodeck {
id String @id @default(cuid())
name String
serial String @unique
approved Boolean @default(false)
location String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
syncLogId String? @relation(fields: [syncLogId], references: [id])
@@index([createdAt])
@@index([syncLogId])
}
-
approvedreflects the “missing approved category” the client needed. - A foreign key to
SyncLoglets us audit each import batch.
After updating the schema, I ran:
npx prisma migrate dev --name add_yodeck_model
which generated the migration and updated the SQLite/Postgres DB.
3. New TypeScript type
To keep the codebase type‑safe, I added a Device union in src/types/device.ts:
// src/types/device.ts
export type Device = Camera | OfficePhone | GuestPhone | Yodeck;
export interface Yodeck {
id: string;
name: string;
serial: string;
approved: boolean;
location?: string;
}
Now any service that works with generic devices can handle Yodeck without casting.
4. Google Sheets provider
src/providers/googleSheets/YodeckGoogleSheetsProvider.ts implements the same interface as the other providers:
// src/providers/googleSheets/YodeckGoogleSheetsProvider.ts
import { GoogleSheetsProvider } from "./GoogleSheetsProvider";
import { Yodeck } from "@/types/device";
export class YodeckGoogleSheetsProvider extends GoogleSheetsProvider<Yodeck> {
protected mapRow(row: string[]): Yodeck {
const [id, name, serial, approved, location] = row;
return {
id,
name,
serial,
approved: approved.toLowerCase() === "true",
location: location || undefined,
};
}
}
The provider reads the tab identified by config.yodeck.gid and returns a typed array of Yodeck objects.
5. Sync service
The core sync logic lives in src/services/yodeckSync.service.ts:
// src/services/yodeckSync.service.ts
import { prisma } from "@/lib/prisma";
import { YodeckGoogleSheetsProvider } from "@/providers/googleSheets/YodeckGoogleSheetsProvider";
import { SyncLog } from "@prisma/client";
export async function runYodeckSync(): Promise<SyncLog> {
const provider = new YodeckGoogleSheetsProvider();
const rows = await provider.fetchRows();
const syncLog = await prisma.syncLog.create({
data: { entity: "YODECK", startedAt: new Date() },
});
const upserts = rows.map((device) =>
prisma.yodeck.upsert({
where: { serial: device.serial },
update: { ...device, syncLogId: syncLog.id },
create: { ...device, syncLogId: syncLog.id },
})
);
await Promise.all(upserts);
return prisma.syncLog.update({
where: { id: syncLog.id },
data: { finishedAt: new Date() },
});
}
Key points:
- Upsert ensures we don’t duplicate devices on repeated syncs.
- The
syncLogIdties each row to the import batch for audit purposes.
6. API routes
Three new endpoints expose the Yodeck data:
| Route | Method | File |
|---|---|---|
/api/yodeck/devices |
GET | src/app/api/yodeck/devices/route.ts |
/api/yodeck/devices/[id] |
GET | src/app/api/yodeck/devices/[id]/route.ts |
/api/yodeck/sync |
POST | src/app/api/yodeck/sync/route.ts |
List devices
// src/app/api/yodeck/devices/route.ts
import { NextResponse } from "next/server";
import { listGeneric } from "@/services/genericDevice.service";
export async function GET() {
const devices = await listGeneric("YODECK");
return NextResponse.json({ devices });
}
Single device
// src/app/api/yodeck/devices/[id]/route.ts
import { NextResponse } from "next/server";
import { getGenericById } from "@/services/genericDevice.service";
export async function GET(_req: Request, { params }: { params: { id: string } }) {
const device = await getGenericById("YODECK", params.id);
return device ? NextResponse.json(device) : NextResponse.notFound();
}
Trigger sync
ts
// src/app/api/yodeck/sync/route.ts
import { NextResponse } from "next/server";
import { runYodeckSync } from "@/services/yodeckSync.service";
export async function POST() {
try {
const result = await runYodeckSync();
return NextResponse.json({ success: true, result });
} catch (e) {
console.error
---
*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building SaaS projects from Playa del Carmen, México.*
*Repo: `zaerohell/pcview` · 2026-08-22*
\#playadev #buildinpublic
Top comments (0)