API Updates
Deploy / deploy (push) Successful in 3m3s

This commit is contained in:
twotalesanimation
2026-07-08 14:38:56 +02:00
parent d40ca8eb55
commit afcef02409
9 changed files with 399 additions and 4 deletions
+10
View File
@@ -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<typeof approvalSchema>) {
}
}
// 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}`);
+5
View File
@@ -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<typeof addCommentSchema>) {
});
}
// 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 };
}
+11 -4
View File
@@ -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 [
+31
View File
@@ -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);
}
+28
View File
@@ -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 // 015
#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
+274
View File
@@ -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 <Arduino.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <Preferences.h>
#include <MD_Parola.h>
#include <MD_MAX72xx.h>
#include <SPI.h>
#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<JsonArray>();
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<JsonArray>();
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();
}
}
+15
View File
@@ -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<void> {
try {
await db.displayEvent.create({ data: { type, shotCode, by } });
} catch {
// Non-critical — never let display events break the main flow
}
}
@@ -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");
+12
View File
@@ -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")
}