From afcef02409925c12b61c2c724de493cb151cd235 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:38:56 +0200 Subject: [PATCH] API Updates --- actions/approvals.ts | 10 + actions/comments.ts | 5 + app/api/dashboard/stats/route.ts | 15 +- app/api/display/events/route.ts | 31 ++ esp32/firmware/config.h | 28 ++ esp32/firmware/firmware.ino | 274 ++++++++++++++++++ lib/display-events.ts | 15 + .../migration.sql | 13 + prisma/schema.prisma | 12 + 9 files changed, 399 insertions(+), 4 deletions(-) create mode 100644 app/api/display/events/route.ts create mode 100644 esp32/firmware/config.h create mode 100644 esp32/firmware/firmware.ino create mode 100644 lib/display-events.ts create mode 100644 prisma/migrations/20260708000000_add_display_events/migration.sql diff --git a/actions/approvals.ts b/actions/approvals.ts index 3cdd382..348f9ef 100644 --- a/actions/approvals.ts +++ b/actions/approvals.ts @@ -9,6 +9,7 @@ import { recalcShotStatus } from "@/lib/shot-status"; import { notifyApprovalChange } from "@/lib/notifications"; import { slackNotifyApproval } from "@/lib/slack"; import { versionLabel } from "@/lib/utils"; +import { createDisplayEvent } from "@/lib/display-events"; const approvalSchema = z.object({ versionId: z.string().cuid(), @@ -107,6 +108,15 @@ export async function submitApproval(data: z.infer) { } } + // Display board event + const contextCode = version.task?.shot?.shotCode ?? version.task?.title ?? null; + if (contextCode && parsed.status !== "PENDING_REVIEW") { + const displayType = + parsed.status === "APPROVED" ? "APPROVED" : + parsed.status === "NEEDS_CHANGES" ? "CHANGES" : "REJECTED"; + await createDisplayEvent(displayType, contextCode, reviewerName); + } + revalidatePath(`/review/${parsed.versionId}`); if (version.task) { revalidatePath(`/tasks/${version.task.id}`); diff --git a/actions/comments.ts b/actions/comments.ts index 4c2ac06..ceee6ac 100644 --- a/actions/comments.ts +++ b/actions/comments.ts @@ -6,6 +6,7 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; import { notifyFeedbackAdded, notifyCommentReply } from "@/lib/notifications"; import { slackNotifyNewFeedback } from "@/lib/slack"; +import { createDisplayEvent } from "@/lib/display-events"; const addCommentSchema = z.object({ versionId: z.string().cuid(), @@ -79,6 +80,10 @@ export async function addComment(data: z.infer) { }); } + // Display board event + const commenter = await db.user.findUnique({ where: { id: session.user.id }, select: { name: true } }); + await createDisplayEvent("COMMENT", shotCode, commenter?.name ?? session.user.email ?? "Someone"); + revalidatePath(`/review/${parsed.versionId}`); return { success: true, comment }; } diff --git a/app/api/dashboard/stats/route.ts b/app/api/dashboard/stats/route.ts index 106a406..b340009 100644 --- a/app/api/dashboard/stats/route.ts +++ b/app/api/dashboard/stats/route.ts @@ -2,10 +2,17 @@ import { NextResponse } from "next/server"; import { auth } from "@/auth"; import { db } from "@/lib/db"; -export async function GET() { - const session = await auth(); - if (!session?.user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); +export async function GET(req: Request) { + // Accept either a logged-in session or the shared display API key + const displayKey = process.env.DISPLAY_API_KEY; + const providedKey = req.headers.get("x-display-key"); + const isDisplayDevice = displayKey && providedKey === displayKey; + + if (!isDisplayDevice) { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } } const [ diff --git a/app/api/display/events/route.ts b/app/api/display/events/route.ts new file mode 100644 index 0000000..a79ff8e --- /dev/null +++ b/app/api/display/events/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { db } from "@/lib/db"; + +function isAuthorized(req: Request): boolean { + const key = process.env.DISPLAY_API_KEY; + if (!key) return false; // key must be set + return req.headers.get("x-display-key") === key; +} + +export async function GET(req: Request) { + if (!isAuthorized(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const after = parseInt(searchParams.get("after") ?? "0", 10); + + // Prune events older than 24 h — fire and forget + db.displayEvent + .deleteMany({ where: { createdAt: { lt: new Date(Date.now() - 86_400_000) } } }) + .catch(() => {}); + + const events = await db.displayEvent.findMany({ + where: { id: { gt: after } }, + orderBy: { id: "asc" }, + take: 10, + select: { id: true, type: true, shotCode: true, by: true }, + }); + + return NextResponse.json(events); +} diff --git a/esp32/firmware/config.h b/esp32/firmware/config.h new file mode 100644 index 0000000..3ab2584 --- /dev/null +++ b/esp32/firmware/config.h @@ -0,0 +1,28 @@ +#pragma once + +// ── WiFi ────────────────────────────────────────────────────────────────────── +#define WIFI_SSID "YourSSID" +#define WIFI_PASSWORD "YourPassword" + +// ── Server ──────────────────────────────────────────────────────────────────── +// IP or hostname of your VFX Review server — no trailing slash +#define SERVER_HOST "192.168.1.100" +#define SERVER_PORT 3000 +#define DISPLAY_API_KEY "your-display-api-key-here" // must match .env DISPLAY_API_KEY + +// ── MAX7219 hardware (hardware SPI) ────────────────────────────────────────── +// Module: 4 × 8×8 MAX7219 daisy-chained = 8×32 display +// DIN → GPIO 23 (MOSI) +// CLK → GPIO 18 (SCK) +// CS → GPIO 5 (SS) +#define CS_PIN 5 +#define NUM_DEVICES 4 // number of 8×8 modules chained + +// ── Display tuning ──────────────────────────────────────────────────────────── +#define BRIGHTNESS 4 // 0–15 +#define SCROLL_SPEED 35 // ms per pixel shift (lower = faster) +#define NOTIFY_HOLD_MS 8000 // how long a notification stays before returning to stats + +// ── Poll intervals ──────────────────────────────────────────────────────────── +#define STATS_INTERVAL_MS 30000 // fetch stats every 30 s +#define EVENTS_INTERVAL_MS 5000 // check for new events every 5 s diff --git a/esp32/firmware/firmware.ino b/esp32/firmware/firmware.ino new file mode 100644 index 0000000..d2b5403 --- /dev/null +++ b/esp32/firmware/firmware.ino @@ -0,0 +1,274 @@ +/* + * VFX Review — ESP32 dot-matrix dashboard + * + * Hardware : ESP32 dev board + 4× MAX7219 8×8 modules chained (8×32 display) + * Libraries (install via Arduino Library Manager): + * - MD_Parola by majicDesigns (≥3.7) + * - MD_MAX72XX by majicDesigns (≥3.5) + * - ArduinoJson by Benoit Blanchon (≥7) + * + * Behaviour: + * Normal → scrolls "APR:142 CHG:12 TODO:38" on a loop + * Event → interrupts with e.g. "OK SH0010" / "REVISE SH0010" / "NOTE SH0010" + * held for NOTIFY_HOLD_MS then resumes stats scroll + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include "config.h" + +// ── Display setup ───────────────────────────────────────────────────────────── +#define HARDWARE_TYPE MD_MAX72XX::FC16_HW +MD_Parola display(HARDWARE_TYPE, CS_PIN, NUM_DEVICES); + +// ── State ───────────────────────────────────────────────────────────────────── +Preferences prefs; + +int approvedCount = 0; +int changesCount = 0; +int todoCount = 0; +int lastEventId = 0; + +bool notifyActive = false; +uint32_t notifyUntil = 0; + +char statsMsg[64]; // "APR:142 CHG:12 TODO:38" +char notifyMsg[64]; // interrupt message + +uint32_t lastStatsFetch = 0; +uint32_t lastEventsFetch = 0; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +void buildStatsMsg() { + snprintf(statsMsg, sizeof(statsMsg), + "APR:%d CHG:%d TODO:%d", + approvedCount, changesCount, todoCount); +} + +// Truncate a string to maxLen chars, safe for display +void truncate(const char* src, char* dst, int maxLen) { + strncpy(dst, src, maxLen); + dst[maxLen] = '\0'; +} + +void connectWiFi() { + Serial.printf("[WiFi] Connecting to %s", WIFI_SSID); + WiFi.mode(WIFI_STA); + WiFi.begin(WIFI_SSID, WIFI_PASSWORD); + while (WiFi.status() != WL_CONNECTED) { + delay(500); + Serial.print('.'); + } + Serial.printf("\n[WiFi] Connected — IP: %s\n", WiFi.localIP().toString().c_str()); +} + +// Make an authenticated GET request; fills `out` with the response body. +// Returns HTTP status code, or -1 on connection failure. +int apiGet(const char* path, String& out) { + if (WiFi.status() != WL_CONNECTED) connectWiFi(); + + char url[256]; + snprintf(url, sizeof(url), "http://%s:%d%s", SERVER_HOST, SERVER_PORT, path); + + HTTPClient http; + http.begin(url); + http.addHeader("x-display-key", DISPLAY_API_KEY); + http.setTimeout(8000); + + int code = http.GET(); + if (code > 0) out = http.getString(); + http.end(); + return code; +} + +// ── Stats fetch ─────────────────────────────────────────────────────────────── + +void fetchStats() { + String body; + int code = apiGet("/api/dashboard/stats", body); + if (code != 200) { + Serial.printf("[Stats] HTTP %d\n", code); + return; + } + + JsonDocument doc; + if (deserializeJson(doc, body)) { Serial.println("[Stats] JSON parse error"); return; } + + approvedCount = doc["approved"] | approvedCount; + changesCount = doc["changes"] | changesCount; + todoCount = doc["todo"] | todoCount; + + buildStatsMsg(); + Serial.printf("[Stats] APR:%d CHG:%d TODO:%d\n", approvedCount, changesCount, todoCount); +} + +// ── Events fetch ────────────────────────────────────────────────────────────── + +void fetchEvents() { + char path[64]; + snprintf(path, sizeof(path), "/api/display/events?after=%d", lastEventId); + + String body; + int code = apiGet(path, body); + if (code != 200) { + Serial.printf("[Events] HTTP %d\n", code); + return; + } + + JsonDocument doc; + if (deserializeJson(doc, body)) { Serial.println("[Events] JSON parse error"); return; } + + JsonArray arr = doc.as(); + if (arr.isNull() || arr.size() == 0) return; + + // Pick the most recent event to display, update lastEventId across all + const char* bestType = nullptr; + const char* bestShotCode = nullptr; + const char* bestBy = nullptr; + int newestId = lastEventId; + + for (JsonObject ev : arr) { + int id = ev["id"] | 0; + const char* t = ev["type"] | ""; + const char* s = ev["shotCode"] | ""; + const char* b = ev["by"] | ""; + + if (id > newestId) { + newestId = id; + bestType = t; + bestShotCode = s; + bestBy = b; + } + } + + if (newestId > lastEventId) { + lastEventId = newestId; + prefs.putInt("lastEvtId", lastEventId); + } + + if (!bestType || !bestShotCode) return; + + // Build notification message + char shotBuf[16]; + truncate(bestShotCode, shotBuf, 12); + + if (strcmp(bestType, "APPROVED") == 0) { + snprintf(notifyMsg, sizeof(notifyMsg), "OK: %s", shotBuf); + } else if (strcmp(bestType, "CHANGES") == 0) { + snprintf(notifyMsg, sizeof(notifyMsg), "REVISE: %s", shotBuf); + } else if (strcmp(bestType, "REJECTED") == 0) { + snprintf(notifyMsg, sizeof(notifyMsg), "REJECT: %s", shotBuf); + } else if (strcmp(bestType, "COMMENT") == 0) { + char byBuf[10]; + truncate(bestBy, byBuf, 8); + snprintf(notifyMsg, sizeof(notifyMsg), "NOTE: %s", shotBuf); + } else { + snprintf(notifyMsg, sizeof(notifyMsg), "%s", shotBuf); + } + + Serial.printf("[Events] id=%d type=%s shot=%s\n", newestId, bestType, bestShotCode); + + notifyActive = true; + notifyUntil = millis() + NOTIFY_HOLD_MS; + + display.displayClear(); + display.displayText(notifyMsg, PA_CENTER, SCROLL_SPEED, 500, PA_SCROLL_LEFT, PA_SCROLL_LEFT); +} + +// ── Arduino setup ───────────────────────────────────────────────────────────── + +void setup() { + Serial.begin(115200); + delay(500); + + // Restore last seen event ID from NVS so we don't replay old events on reboot + prefs.begin("vfxdisp", false); + lastEventId = prefs.getInt("lastEvtId", 0); + Serial.printf("[Init] lastEventId restored = %d\n", lastEventId); + + // Display init + display.begin(); + display.setIntensity(BRIGHTNESS); + display.displayClear(); + display.setTextAlignment(PA_LEFT); + display.setScrollSpacing(4); + + // Show connecting message + display.displayText("Connecting...", PA_LEFT, SCROLL_SPEED, 0, PA_SCROLL_LEFT, PA_SCROLL_LEFT); + while (!display.displayAnimate()) {} + + connectWiFi(); + + // Initial data fetch + fetchStats(); + + // Prime the events endpoint — catch up to current without showing stale events + // We do a silent fetch just to advance lastEventId to now + { + char path[64]; + snprintf(path, sizeof(path), "/api/display/events?after=%d", lastEventId); + String body; + int code = apiGet(path, body); + if (code == 200) { + JsonDocument doc; + if (!deserializeJson(doc, body)) { + JsonArray arr = doc.as(); + for (JsonObject ev : arr) { + int id = ev["id"] | 0; + if (id > lastEventId) lastEventId = id; + } + prefs.putInt("lastEvtId", lastEventId); + } + } + } + + // Kick off the scrolling stats message + buildStatsMsg(); + display.displayClear(); + display.displayText(statsMsg, PA_LEFT, SCROLL_SPEED, 0, PA_SCROLL_LEFT, PA_SCROLL_LEFT); + + lastStatsFetch = millis(); + lastEventsFetch = millis(); +} + +// ── Arduino loop ────────────────────────────────────────────────────────────── + +void loop() { + uint32_t now = millis(); + + // ── Timed fetches ── + if (now - lastStatsFetch >= STATS_INTERVAL_MS) { + lastStatsFetch = now; + fetchStats(); + // If no notification is active, refresh the scrolling text immediately + if (!notifyActive) { + display.displayClear(); + display.displayText(statsMsg, PA_LEFT, SCROLL_SPEED, 0, PA_SCROLL_LEFT, PA_SCROLL_LEFT); + } + } + + if (now - lastEventsFetch >= EVENTS_INTERVAL_MS) { + lastEventsFetch = now; + if (!notifyActive) fetchEvents(); // don't overlap notifications + } + + // ── Notification expiry ── + if (notifyActive && now >= notifyUntil) { + notifyActive = false; + display.displayClear(); + display.displayText(statsMsg, PA_LEFT, SCROLL_SPEED, 0, PA_SCROLL_LEFT, PA_SCROLL_LEFT); + } + + // ── Animate display ── + // When a scroll finishes, restart it (loops the message) + if (display.displayAnimate()) { + display.displayReset(); + } +} diff --git a/lib/display-events.ts b/lib/display-events.ts new file mode 100644 index 0000000..70ebca5 --- /dev/null +++ b/lib/display-events.ts @@ -0,0 +1,15 @@ +import { db } from "@/lib/db"; + +export type DisplayEventType = "APPROVED" | "CHANGES" | "COMMENT" | "REJECTED"; + +export async function createDisplayEvent( + type: DisplayEventType, + shotCode: string, + by: string +): Promise { + try { + await db.displayEvent.create({ data: { type, shotCode, by } }); + } catch { + // Non-critical — never let display events break the main flow + } +} diff --git a/prisma/migrations/20260708000000_add_display_events/migration.sql b/prisma/migrations/20260708000000_add_display_events/migration.sql new file mode 100644 index 0000000..3d4ef85 --- /dev/null +++ b/prisma/migrations/20260708000000_add_display_events/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "display_events" ( + "id" SERIAL NOT NULL, + "type" TEXT NOT NULL, + "shotCode" TEXT NOT NULL, + "by" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "display_events_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "display_events_createdAt_idx" ON "display_events"("createdAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b729048..3171aca 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -557,3 +557,15 @@ model SystemConfig { @@map("system_config") } + +/// Physical display event log — polled by ESP32 dot-matrix display +model DisplayEvent { + id Int @id @default(autoincrement()) + type String // APPROVED | CHANGES | COMMENT | REJECTED + shotCode String + by String // actor name (reviewer / commenter) + createdAt DateTime @default(now()) + + @@index([createdAt]) + @@map("display_events") +}