Skip to content
101 changes: 101 additions & 0 deletions migrations/versions/155-prints-library.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
-- 155-prints-library.sql — the Prints library (server/prints.ts,
-- src/types/prints.ts): a team's 3D print files, organised two ways at once.
-- print_folders: where a file lives. parent_id NULL is the library root.
-- Names are unique per parent, ignoring case.
-- print_files: one row per file; the bytes live on the VM's disk under
-- PRINTS_DIR at storage_key (never derived from a user-typed name), with
-- an optional PNG thumbnail at thumb_key (thumb_size counts toward the
-- workspace quota with size). Smart groups are computed from
-- these details: robot_id (its season comes from the robot) or season,
-- cad_part_id (its assembly section is the subsystem and its quantity the
-- copies needed unless set here), material, status. source is 'upload'
-- or 'control-kit'; ck_part_key is the Control Kit identity it prints.
-- print_collections / print_collection_files: groups a team makes itself
-- ("Comp spares", "Worlds kit"). A file belongs to any number of them
-- without being copied.
-- print_activity: every change, never updated.
-- manage_prints: the role permission for uploading, moving, renaming,
-- deleting and editing details. Every role that can manage CAD gets
-- it (admins have "*"); members without it view and download.
-- New tables only; nothing existing changes.
BEGIN;

CREATE TABLE IF NOT EXISTS print_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
team_id INTEGER NOT NULL,
parent_id INTEGER,
name TEXT NOT NULL,
created_by INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_print_folders_name ON print_folders (team_id, COALESCE(parent_id, 0), name COLLATE NOCASE);

CREATE TABLE IF NOT EXISTS print_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
team_id INTEGER NOT NULL,
folder_id INTEGER,
name TEXT NOT NULL,
kind TEXT NOT NULL,
size INTEGER NOT NULL,
sha256 TEXT NOT NULL,
storage_key TEXT NOT NULL,
thumb_key TEXT,
thumb_size INTEGER NOT NULL DEFAULT 0,
notes TEXT NOT NULL DEFAULT '',
robot_id INTEGER,
season INTEGER,
cad_part_id INTEGER,
subsystem TEXT,
material TEXT,
status TEXT NOT NULL DEFAULT 'to_print',
copies_needed INTEGER,
copies_printed INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'upload',
ck_part_key TEXT,
uploaded_by INTEGER,
uploaded_by_name TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_print_files_name ON print_files (team_id, COALESCE(folder_id, 0), name COLLATE NOCASE);
CREATE INDEX IF NOT EXISTS idx_print_files_team ON print_files (team_id, folder_id);

CREATE TABLE IF NOT EXISTS print_collections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
team_id INTEGER NOT NULL,
name TEXT NOT NULL,
created_by INTEGER,
created_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_print_collections_name ON print_collections (team_id, name COLLATE NOCASE);

CREATE TABLE IF NOT EXISTS print_collection_files (
collection_id INTEGER NOT NULL,
file_id INTEGER NOT NULL,
team_id INTEGER NOT NULL,
added_at TEXT NOT NULL,
PRIMARY KEY (collection_id, file_id)
);
CREATE INDEX IF NOT EXISTS idx_print_collection_files_file ON print_collection_files (file_id);

CREATE TABLE IF NOT EXISTS print_activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
team_id INTEGER NOT NULL,
file_id INTEGER,
folder_id INTEGER,
member_id INTEGER,
member_name TEXT,
action TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '{}',
at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_print_activity_team ON print_activity (team_id, id);
CREATE INDEX IF NOT EXISTS idx_print_activity_file ON print_activity (file_id);

UPDATE roles SET permissions = json_insert(permissions, '$[#]', 'manage_prints')
WHERE json_valid(permissions)
AND EXISTS (SELECT 1 FROM json_each(permissions) WHERE value = 'manage_cad')
AND NOT EXISTS (SELECT 1 FROM json_each(permissions) WHERE value = 'manage_prints');

COMMIT;
16 changes: 14 additions & 2 deletions server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import { registerNotebookReviewRoutes } from "./server/notebookReviews.js";
import { registerChecklistRoutes } from "./server/checklists.js";
import { createControlKitLink, deleteGrantsSql } from "./server/controlKitLink.js";
import { createCadRobots, defaultRobotId, teamRobotId } from "./server/cadRobots.js";
import { createPrints, PRINTS_WORKSPACE_DELETE } from "./server/prints.js";
import { registerStickyNoteRoutes } from "./server/stickyNotes.js";
import { isRefType, resolveRef, type RefDeps } from "./server/refs.js";
import { createRefsFilter, refsBlock, stripModelRefs, validateRefs } from "./server/brunoRefs.js";
Expand Down Expand Up @@ -2024,6 +2025,7 @@ const ROLE_PERMISSIONS = [
{ key: "manage_checklists", label: "Manage checklists" },
{ key: "manage_documentation", label: "Manage documentation" },
{ key: "manage_cad", label: "Manage CAD robots and part choices" },
{ key: "manage_prints", label: "Upload and organize 3D prints" },
{ key: "edit_notebook", label: "Edit notebook pages" },
{ key: "organize_notebook", label: "Organize notebooks" },
{ key: "delete_notebook", label: "Delete notebook items" },
Expand Down Expand Up @@ -2096,14 +2098,14 @@ async function ensureRolesSeeded(teamId: number | null | undefined) {
if (!names.has("Member")) {
const memberRole = (await dbRun(
"INSERT INTO roles (team_id, name, color, permissions, position, is_system) VALUES (?,?,?,?,?,1)",
teamId, "Member", "#71717A", JSON.stringify(["view_ai", "manage_calendar", "manage_communications", "manage_tasks", "manage_outreach", "manage_checklists", "edit_notebook", "organize_notebook", "manage_cad"]), 1
teamId, "Member", "#71717A", JSON.stringify(["view_ai", "manage_calendar", "manage_communications", "manage_tasks", "manage_outreach", "manage_checklists", "edit_notebook", "organize_notebook", "manage_cad", "manage_prints"]), 1
)) as any;
memberRoleId = Number(memberRole.lastInsertRowid);
}
if (!names.has("Verified Member")) {
await dbRun(
"INSERT INTO roles (team_id, name, color, permissions, position, is_system) VALUES (?,?,?,?,?,1)",
teamId, "Verified Member", "#22C55E", JSON.stringify(["view_ai", "manage_inventory", "manage_calendar", "manage_tasks", "manage_documentation", "manage_communications", "manage_outreach", "manage_attendance", "manage_code", "manage_budget", "manage_checklists", "edit_notebook", "organize_notebook", "manage_cad"]), 2
teamId, "Verified Member", "#22C55E", JSON.stringify(["view_ai", "manage_inventory", "manage_calendar", "manage_tasks", "manage_documentation", "manage_communications", "manage_outreach", "manage_attendance", "manage_code", "manage_budget", "manage_checklists", "edit_notebook", "organize_notebook", "manage_cad", "manage_prints"]), 2
);
}

Expand Down Expand Up @@ -2402,12 +2404,17 @@ async function startServer() {
// are registered with the CAD routes, behind the guard.
// CAD by season and robot; Control Kit reads the part choices made in it.
const cadRobots = createCadRobots({ dbGet, dbAll, dbRun, dbBatchResults, requireAuth: requireAuth as any, hasPerm });
// The Prints library (server/prints.ts): 3D print files on the VM's disk, in folders and groups.
const prints = createPrints({ dbGet, dbAll, dbRun, dbBatchResults, requireAuth: requireAuth as any, hasPerm, env: process.env });
const controlKit = createControlKitLink({
dbGet, dbAll, dbRun, dbBatchResults, env: process.env, decisionsFor: cadRobots.decisionsFor,
requireAuth: requireAuth as any, authEmail: (auth) => authEmail(auth as any),
// Declared further down; only called per request, after startup.
storeFile: (opts) => storeFile(opts), fileUrl: (id) => fileUrl(id),
deleteStoredFile: (url) => deleteStoredFileByUrl(url),
// Its STEP zip's 3D-print files are filed in the Prints library (server/prints.ts), when the member who linked
// Control Kit may change it (manage_prints).
fileStepZip: async (o) => ((await hasPerm({ memberId: o.memberId, teamId: o.teamId }, "manage_prints")) ? prints.fileControlKitStepZip(o) : null),
appUrl: (req) => (process.env.APP_URL || "").replace(/\/$/, "") || getBaseUrl(req),
});
controlKit.registerBearerRoutes(app);
Expand Down Expand Up @@ -5420,6 +5427,9 @@ async function startServer() {
{ sql: "DELETE FROM cad_parts WHERE team_id = ?", args: [teamId] },
{ sql: "DELETE FROM cad_imports WHERE team_id = ?", args: [teamId] },
{ sql: "DELETE FROM cad_docs WHERE team_id = ?", args: [teamId] },
// The Prints library: rows here, bytes on disk after the batch commits.
...PRINTS_WORKSPACE_DELETE.others.map((sql) => ({ sql, args: [teamId] })),
{ sql: PRINTS_WORKSPACE_DELETE.files, args: [teamId] },
{ sql: "DELETE FROM checkin_sessions WHERE team_id = ?", args: [teamId] },
// References teams without a cascade.
{ sql: "DELETE FROM call_moderation_log WHERE team_id = ?", args: [teamId] },
Expand Down Expand Up @@ -5454,6 +5464,7 @@ async function startServer() {
const deletedIds = (sql: string) => (results[stmts.findIndex((s) => s.sql === sql)]?.rows || []).map((row: any) => Number(row.id));
// Remove the private object copies only after the database deletion commits.
for (const fileId of deletedIds(FILES_STMT)) await deleteStoredRow(fileId);
await prints.removeStored(results[stmts.findIndex((s) => s.sql === PRINTS_WORKSPACE_DELETE.files)]?.rows || []).catch((e) => console.error("Deleting print files failed:", e?.message || e));
for (const imageId of deletedIds(IMAGES_STMT)) await deleteMessageImageRow(imageId).catch((e) => console.error("Deleting a chat image failed:", e?.message || e));
for (const id of memberIds) if (id !== keepMemberId) disconnectMember(teamId, id);
}
Expand Down Expand Up @@ -16438,6 +16449,7 @@ NOTES>>>`;
return auth.teamId as number;
};
cadRobots.registerRoutes(app);
prints.registerRoutes(app);
// Lists take ?robot= to show one robot's rows; a new row goes to the robot it names, else the team's latest main robot.
const cadRobotFilter = async (teamId: number, q: unknown) => {
if (q === undefined || q === "") return { sql: "", args: [] as any[] };
Expand Down
Loading