@@ -9,6 +9,7 @@ import { recalcShotStatus } from "@/lib/shot-status";
|
|||||||
import { notifyApprovalChange } from "@/lib/notifications";
|
import { notifyApprovalChange } from "@/lib/notifications";
|
||||||
import { slackNotifyApproval } from "@/lib/slack";
|
import { slackNotifyApproval } from "@/lib/slack";
|
||||||
import { versionLabel } from "@/lib/utils";
|
import { versionLabel } from "@/lib/utils";
|
||||||
|
import { createDisplayEvent } from "@/lib/display-events";
|
||||||
|
|
||||||
const approvalSchema = z.object({
|
const approvalSchema = z.object({
|
||||||
versionId: z.string().cuid(),
|
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}`);
|
revalidatePath(`/review/${parsed.versionId}`);
|
||||||
if (version.task) {
|
if (version.task) {
|
||||||
revalidatePath(`/tasks/${version.task.id}`);
|
revalidatePath(`/tasks/${version.task.id}`);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { notifyFeedbackAdded, notifyCommentReply } from "@/lib/notifications";
|
import { notifyFeedbackAdded, notifyCommentReply } from "@/lib/notifications";
|
||||||
import { slackNotifyNewFeedback } from "@/lib/slack";
|
import { slackNotifyNewFeedback } from "@/lib/slack";
|
||||||
|
import { createDisplayEvent } from "@/lib/display-events";
|
||||||
|
|
||||||
const addCommentSchema = z.object({
|
const addCommentSchema = z.object({
|
||||||
versionId: z.string().cuid(),
|
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}`);
|
revalidatePath(`/review/${parsed.versionId}`);
|
||||||
return { success: true, comment };
|
return { success: true, comment };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,18 @@ import { NextResponse } from "next/server";
|
|||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
export async function GET() {
|
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();
|
const session = await auth();
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [
|
const [
|
||||||
approved,
|
approved,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
@@ -557,3 +557,15 @@ model SystemConfig {
|
|||||||
|
|
||||||
@@map("system_config")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user