Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions migrations/versions/156-print-queue.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- 156-print-queue.sql — the Prints queue (server/prints.ts, src/types/prints.ts):
-- the files still to print, in the order the team will print them.
-- print_files.priority: 'urgent', 'high', 'normal' (default) or 'low'.
-- print_files.due_date: the day it's needed by, "YYYY-MM-DD", or NULL.
-- print_files.queue_pos: where a person dragged it in the queue (1 is next),
-- or NULL when nobody has placed it. Unplaced files slot in by priority,
-- then due date (queueOrder()). Changing a file's priority or due date, or
-- marking it printed, clears its place.
-- New columns only; nothing existing changes.
BEGIN;

ALTER TABLE print_files ADD COLUMN priority TEXT NOT NULL DEFAULT 'normal';
ALTER TABLE print_files ADD COLUMN due_date TEXT;
ALTER TABLE print_files ADD COLUMN queue_pos INTEGER;

COMMIT;
86 changes: 84 additions & 2 deletions server/__tests__/prints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { PassThrough } from "node:stream";
import { writeFileSync } from "node:fs";
import { startTestServer, seedTeam, seedMember, type TestServer } from "./helpers/testServer";
import { withSession } from "./helpers/session";
import { cleanName, splitPath, uniqueName, printKind, safeName, matchesFilters, groupLine, folderAndBelow, type PrintFile } from "../../src/types/prints";
import { cleanName, splitPath, uniqueName, printKind, safeName, matchesFilters, groupLine, folderAndBelow, queueOrder, moveInOrder, cleanDueDate, dueState, type PrintFile } from "../../src/types/prints";
import { PrintStore, createPrints, controlKitPrintPath, printLimits, partForStepFile } from "../prints";
import { createHash } from "node:crypto";
import { seasonName } from "../../src/types/cadBom";
Expand Down Expand Up @@ -133,7 +133,7 @@ describe("names, paths and storage keys (pure)", () => {
expect(printLimits({ PRINTS_MAX_FILE_MB: "50", PRINTS_QUOTA_MB: "-1", PRINTS_MIN_FREE_MB: "0" })).toEqual({ maxFile: 50 * MB, quota: 2048 * MB, minFree: 0 });
});
it("combines group filters and counts what's left to print", () => {
const f = (o: Partial<PrintFile>): PrintFile => ({ id: 1, folderId: null, name: "x.stl", kind: "stl", size: 1, createdAt: "", updatedAt: "", uploadedBy: null, notes: "", robotId: null, season: null, cadPart: null, subsystem: null, subsystemFrom: null, material: null, status: "to_print", copiesNeeded: null, copiesNeededFrom: null, copiesPrinted: 0, collections: [], hasThumb: false, source: "upload", ...o });
const f = (o: Partial<PrintFile>): PrintFile => ({ id: 1, folderId: null, name: "x.stl", kind: "stl", size: 1, createdAt: "", updatedAt: "", uploadedBy: null, notes: "", robotId: null, season: null, cadPart: null, subsystem: null, subsystemFrom: null, material: null, status: "to_print", copiesNeeded: null, copiesNeededFrom: null, copiesPrinted: 0, priority: "normal", dueDate: null, queuePos: null, collections: [], hasThumb: false, source: "upload", ...o });
const files = [f({ subsystem: "Intake", material: "PETG" }), f({ subsystem: "intake", material: "PLA" }), f({ subsystem: "Intake", material: "PETG", status: "printed" }), f({ subsystem: "Lift", material: "PETG", status: "failed" })];
const hit = files.filter((x) => matchesFilters(x, { subsystem: "Intake", material: "PETG", status: "to_print" }));
expect(hit).toHaveLength(1);
Expand Down Expand Up @@ -361,6 +361,88 @@ describe("review fixes", () => {
});
});

describe("the print queue", () => {
const f = (id: number, o: Partial<PrintFile> = {}): PrintFile => ({ id, folderId: null, name: `${id}.stl`, kind: "stl", size: 1, createdAt: `2026-10-0${id}T00:00:00Z`, updatedAt: "", uploadedBy: null, notes: "", robotId: null, season: null, cadPart: null, subsystem: null, subsystemFrom: null, material: null, status: "to_print", copiesNeeded: null, copiesNeededFrom: null, copiesPrinted: 0, priority: "normal", dueDate: null, queuePos: null, collections: [], hasThumb: false, source: "upload", ...o });
const order = (files: PrintFile[]) => queueOrder(files).map((x) => x.id);

it("lines up by priority, then due date, then age, leaving out what's printing or printed", () => {
const files = [f(1), f(2, { priority: "low" }), f(3, { dueDate: "2026-11-02" }), f(4, { priority: "urgent" }), f(5, { dueDate: "2026-11-01", status: "failed" }), f(6, { status: "printing" }), f(7, { status: "printed" })];
expect(order(files)).toEqual([4, 5, 3, 1, 2]);
});
it("keeps the order people dragged, and slots unplaced files in before the first placed one they beat", () => {
// Dragged: low first, then high.
const placed = [f(1, { priority: "low", queuePos: 1 }), f(2, { priority: "high", queuePos: 2 })];
expect(order(placed)).toEqual([1, 2]);
// An urgent new file goes to the top and a normal one ahead of the low one it beats; a newer low one beats neither.
expect(order([...placed, f(3, { priority: "urgent" }), f(4), f(5, { priority: "low" })])).toEqual([3, 4, 1, 2, 5]);
expect(moveInOrder([1, 2, 3, 4], 4, 0)).toEqual([4, 1, 2, 3]);
expect(moveInOrder([1, 2, 3, 4], 1, 2)).toEqual([2, 3, 1, 4]);
expect(moveInOrder([1, 2, 3], 3, 99)).toEqual([1, 2, 3]);
});
it("takes only real calendar days as due dates", () => {
expect(cleanDueDate("2026-11-03")).toBe("2026-11-03");
expect(cleanDueDate("")).toBeNull();
expect(cleanDueDate(null)).toBeNull();
for (const bad of ["2026-02-30", "2026-13-01", "0002-11-03", "11/03/2026", "2026-11-3", 20261103]) expect(cleanDueDate(bad)).toBeUndefined();
expect(dueState("2026-10-29", "2026-10-30")).toBe("overdue");
expect(dueState("2026-10-30", "2026-10-30")).toBe("today");
expect(dueState("2026-11-02", "2026-10-30")).toBe("soon");
expect(dueState("2026-11-10", "2026-10-30")).toBe("later");
});

it("saves one shared order, and a new priority, due date or finished print gives up the file's place", async () => {
// One at a time, so they're created in this order.
const [a, b, c, d] = [await uploadFile(admin, { name: "q-a.stl" }), await uploadFile(admin, { name: "q-b.stl" }), await uploadFile(admin, { name: "q-c.stl" }), await uploadFile(admin, { name: "q-d.stl" })];
const mine = [a, b, c, d].map((x) => x.body.id as number);
const theirs = await t.db.execute({ sql: "INSERT INTO print_files (team_id, name, kind, size, sha256, storage_key, created_at, updated_at) VALUES (?, 'theirs.stl', 'stl', 1, 'x', 'k', 'x', 'x')", args: [other] });
const theirId = Number(theirs.lastInsertRowid);
const queue = async () => (await lib()).files.filter((x: PrintFile) => mine.includes(x.id));
const ordered = async () => queueOrder(await queue()).map((x) => x.id);

// Priority and due date are details.
expect((await t.patch(`/api/prints/files/${c.body.id}`, { priority: "urgent", dueDate: "2026-11-03" }, admin)).body).toMatchObject({ priority: "urgent", dueDate: "2026-11-03", queuePos: null });
expect((await t.patch(`/api/prints/files/${c.body.id}`, { priority: "asap" }, admin)).status).toBe(400);
expect((await t.patch(`/api/prints/files/${c.body.id}`, { dueDate: "2026-02-30" }, admin)).status).toBe(400);
expect(await ordered()).toEqual([c.body.id, a.body.id, b.body.id, d.body.id]);

// Members without manage_prints can't reorder.
expect((await t.post("/api/prints/queue", { ids: [d.body.id] }, student)).status).toBe(403);
expect((await t.post("/api/prints/queue", { ids: "nope" }, admin)).status).toBe(400);
// A drag saves the whole order; another workspace's file takes no place.
const r = await t.post("/api/prints/queue", { ids: [d.body.id, theirId, c.body.id, a.body.id, b.body.id] }, admin);
expect(r.status).toBe(200);
expect(r.body.files).toBe(4);
expect(await ordered()).toEqual([d.body.id, c.body.id, a.body.id, b.body.id]);
expect((await t.db.execute({ sql: "SELECT queue_pos FROM print_files WHERE id = ?", args: [theirId] })).rows[0].queue_pos).toBeNull();
// Everyone sees it.
expect(queueOrder((await lib(student)).files.filter((x: PrintFile) => mine.includes(x.id))).map((x) => x.id)).toEqual([d.body.id, c.body.id, a.body.id, b.body.id]);

// A new priority gives up the place: b (now urgent) slots in at the top, ahead of every placed file it beats.
expect((await t.patch(`/api/prints/files/${b.body.id}`, { priority: "urgent" }, admin)).body.queuePos).toBeNull();
expect(await ordered()).toEqual([b.body.id, d.body.id, c.body.id, a.body.id]);
// Editing anything else keeps it.
expect((await t.patch(`/api/prints/files/${d.body.id}`, { notes: "0.2 mm layers" }, admin)).body.queuePos).not.toBeNull();
// Printed files leave the queue and their place, whichever way they're marked.
expect((await t.patch(`/api/prints/files/${d.body.id}`, { status: "printed" }, admin)).body.queuePos).toBeNull();
await t.post("/api/prints/files/bulk", { ids: [c.body.id], action: "status", status: "printed" }, admin);
expect((await queue()).find((x: PrintFile) => x.id === c.body.id)!.queuePos).toBeNull();
// A printed file in an order takes no place.
await t.post("/api/prints/queue", { ids: [a.body.id, d.body.id, b.body.id] }, admin);
expect((await queue()).filter((x: PrintFile) => x.queuePos != null).map((x: PrintFile) => [x.id, x.queuePos])).toEqual([[a.body.id, 1], [b.body.id, 3]]);
expect(await ordered()).toEqual([a.body.id, b.body.id]);

// An empty order goes back to priority order.
expect((await t.post("/api/prints/queue", { ids: [] }, admin)).status).toBe(200);
expect((await queue()).every((x: PrintFile) => x.queuePos == null)).toBe(true);
expect(await ordered()).toEqual([b.body.id, a.body.id]);
const act = (await get("/api/prints/activity", admin)).body.map((x: any) => x.action);
expect(act).toEqual(expect.arrayContaining(["queue_order", "queue_reset"]));

await t.post("/api/prints/files/bulk", { ids: mine, action: "delete" }, admin);
await t.db.execute({ sql: "DELETE FROM print_files WHERE id = ?", args: [theirId] });
});
});

describe("groups, details and downloads", () => {
it("files one print in several collections without copying it, and marks a selection printed", async () => {
const a = await uploadFile(admin, { name: "hub.stl" });
Expand Down
54 changes: 48 additions & 6 deletions server/prints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
//
// Control Kit: fileControlKitPrint() files a print sent for a robot under
// "Season / Robot / Control Kit" (see the end of this file).
//
// The queue (migration 156): each file has a priority and an optional due
// date, and the files still to print can be dragged into an order the whole
// workspace shares (POST /api/prints/queue). See queueOrder() in
// src/types/prints.ts for how placed and unplaced files mix.
import type { Express } from "express";
import multer from "multer";
import { createHash, randomBytes } from "node:crypto";
Expand All @@ -28,7 +33,7 @@ import path from "node:path";
import { seasonName, isSeason } from "../src/types/cadBom.js";
import { DEFAULT_SUBSYSTEMS, cleanSubsystems } from "../src/types/teamModules.js";
import {
canPreview, cleanMaterial, cleanName, cleanSubsystem, isPrintStatus, printKind, safeName, uniqueName,
canPreview, cleanDueDate, cleanMaterial, cleanName, cleanSubsystem, isPrintPriority, isPrintStatus, printKind, safeName, uniqueName,
type PrintFile, type PrintKind, type PrintStatus,
} from "../src/types/prints.js";
import { readZip, writeZip, uniqueZipNames, ZIP_MAX_BYTES, ZIP_MAX_ENTRIES, type ZipReadEntry } from "./zipStream.js";
Expand All @@ -49,6 +54,7 @@ export const MANAGE_PRINTS = "manage_prints";
const MB = 1024 * 1024;
const THUMB_MAX = 512 * 1024;
const MAX_BULK = 1000;
const MAX_QUEUE = 5000;
const MAX_COLLECTIONS = 60;
const MAX_FOLDERS = 2000;
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
Expand Down Expand Up @@ -235,7 +241,9 @@ export function createPrints(deps: PrintDeps) {
material: r.material || null, status: isPrintStatus(r.status) ? r.status : "to_print",
copiesNeeded: r.copies_needed != null ? Number(r.copies_needed) : cadQty,
copiesNeededFrom: r.copies_needed != null ? "manual" : cadQty != null ? "cad" : null,
copiesPrinted: Number(r.copies_printed) || 0, collections: collections.get(Number(r.id)) ?? [], hasThumb: !!r.thumb_key,
copiesPrinted: Number(r.copies_printed) || 0,
priority: isPrintPriority(r.priority) ? r.priority : "normal", dueDate: r.due_date || null, queuePos: r.queue_pos == null ? null : Number(r.queue_pos),
collections: collections.get(Number(r.id)) ?? [], hasThumb: !!r.thumb_key,
source: r.source === "control-kit" ? "control-kit" : "upload",
};
}
Expand Down Expand Up @@ -512,7 +520,7 @@ export function createPrints(deps: PrintDeps) {
} catch (e) { sendError(res, e, "file"); }
});

// Details: name, folder, notes, robot/season, CAD part, subsystem, material, status, copies.
// Details: name, folder, notes, robot/season, CAD part, subsystem, material, status, copies, priority, due date.
app.patch("/api/prints/files/:id", async (req, res) => {
try {
const a = await access(req, res, true); if (!a) return;
Expand Down Expand Up @@ -593,6 +601,17 @@ export function createPrints(deps: PrintDeps) {
: row.cad_part_id ? Number((await dbGet("SELECT quantity FROM cad_parts WHERE id = ? AND team_id = ?", row.cad_part_id, a.teamId))?.quantity ?? NaN) : NaN;
if (need != null && Number.isFinite(need) && need > Number(row.copies_printed)) set("copies_printed", need, "copiesPrinted");
}
if (body.priority !== undefined) {
if (!isPrintPriority(body.priority)) return res.status(400).json({ error: "Priority is urgent, high, normal or low" });
set("priority", body.priority);
}
if (body.dueDate !== undefined) {
const due = cleanDueDate(body.dueDate);
if (due === undefined) return res.status(400).json({ error: "Pick a due date" });
set("due_date", due, "dueDate");
}
// A new priority or due date, or a finished print, gives up its place in the queue: it slots in again by priority.
if (row.queue_pos != null && (changes.priority !== undefined || changes.dueDate !== undefined || changes.status === "printed")) sets.push("queue_pos = NULL");
if (!sets.length) return res.json(await oneFile(a.teamId, Number(row.id)));
const by = { id: a.auth.memberId, name: await memberName(a.auth.memberId) };
const stmts: Stmt[] = [{ sql: `UPDATE print_files SET ${sets.join(", ")}, updated_at = ? WHERE id = ? AND team_id = ? AND ${FOLDER_OK}`, args: [...args, now(), row.id, a.teamId, folderId, folderId, a.teamId] }];
Expand Down Expand Up @@ -667,12 +686,13 @@ export function createPrints(deps: PrintDeps) {
const status: PrintStatus = body.status;
for (const r of rows.filter((x) => x.status !== status)) {
stmts.push({
// Marking printed counts every copy needed as printed (when the count is known).
// Marking printed counts every copy needed as printed (when the count is known), and gives up its queue place.
sql: `UPDATE print_files SET status = ?, updated_at = ?, copies_printed = CASE WHEN ? = 'printed'
THEN MAX(copies_printed, COALESCE(copies_needed, (SELECT p.quantity FROM cad_parts p WHERE p.id = print_files.cad_part_id AND p.team_id = print_files.team_id), copies_printed))
ELSE copies_printed END
ELSE copies_printed END,
queue_pos = CASE WHEN ? = 'printed' THEN NULL ELSE queue_pos END
WHERE id = ? AND team_id = ?`,
args: [status, t, status, r.id, a.teamId],
args: [status, t, status, status, r.id, a.teamId],
});
stmts.push(logStmt(a.teamId, by, "status", { fileId: Number(r.id), folderId: r.folder_id ?? null, detail: { from: r.status, to: status } }));
}
Expand Down Expand Up @@ -715,6 +735,28 @@ export function createPrints(deps: PrintDeps) {
} catch (e) { sendError(res, e, "bulk"); }
});

// The queue's order: the files still to print as the person left it, next first. Those files are placed 1, 2, 3…;
// every other file gives up its place and slots in by priority and due date. An empty list puts the whole queue
// back in priority order. The last order sent wins.
app.post("/api/prints/queue", async (req, res) => {
try {
const a = await access(req, res, true); if (!a) return;
if (!Array.isArray(req.body?.ids)) return res.status(400).json({ error: "Send the queue's order" });
const list = ids(req.body.ids);
if (list.length > MAX_QUEUE) return res.status(400).json({ error: `A queue holds at most ${MAX_QUEUE} files` });
const by = { id: a.auth.memberId, name: await memberName(a.auth.memberId) };
const order = JSON.stringify(list);
const [, placed] = await deps.dbBatchResults([
{ sql: "UPDATE print_files SET queue_pos = NULL WHERE team_id = ? AND queue_pos IS NOT NULL", args: [a.teamId] },
// Only this workspace's files that still need printing take a place.
{ sql: `UPDATE print_files SET queue_pos = (SELECT j.key + 1 FROM json_each(?) j WHERE j.value = print_files.id)
WHERE team_id = ? AND status IN ('to_print', 'failed') AND id IN (SELECT value FROM json_each(?))`, args: [order, a.teamId, order] },
logStmt(a.teamId, by, list.length ? "queue_order" : "queue_reset", { detail: { files: list.length } }),
]);
res.json({ success: true, files: placed.changes });
} catch (e) { sendError(res, e, "queue"); }
});

app.get("/api/prints/files/:id/download", async (req, res) => {
try {
const a = await access(req, res, false); if (!a) return;
Expand Down
Loading