printhive/db/schema.sql
dlawler489 e98edaa9ed Add a Printers page for any number of printers, and show spools' RFID tags
Printers move out of Settings into their own table and page: each has an
optional nickname (the model is shown when blank), a model from the Bambu
lineup with a line drawing that follows the selection, an AMS type
suggested from the model, and its address, serial and access code. Access
codes are never returned to the browser. The printer set up in Settings is
migrated automatically and its settings rows removed.

Background sync covers every printer. Grams left are filled in only from an
AMS that measures (AMS, AMS 2 Pro, AMS HT), never from an AMS lite.

Spools now show their RFID tag ID, product code and filament code, and say
which printer they're loaded in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:00:43 +10:00

332 lines
15 KiB
SQL

-- STL Library Manager schema
-- A "model" is one logical download (a Thingiverse thing, a Printables model, a
-- single uploaded STL). A model owns one or more files (the actual .stl/.3mf/...).
-- Per-license commercial-use policy. Auto-registered as licenses are seen;
-- editable in Settings. commercial: true=sellable, false=non-commercial, null=unknown.
CREATE TABLE IF NOT EXISTS license_rules (
name TEXT PRIMARY KEY,
commercial BOOLEAN
);
-- App users (email/password auth).
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- One-time password-reset tokens (token stored hashed).
CREATE TABLE IF NOT EXISTS password_resets (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used BOOLEAN NOT NULL DEFAULT false
);
CREATE INDEX IF NOT EXISTS idx_password_resets_token ON password_resets(token_hash);
-- Simple key/value app settings (API tokens, site auth). Editable from the UI.
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS designers (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
source_site TEXT, -- thingiverse | printables | makerworld | ...
profile_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (name, source_site)
);
CREATE TABLE IF NOT EXISTS projects (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
date_created TIMESTAMPTZ NOT NULL DEFAULT now(),
date_modified TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS tags (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS models (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
source_site TEXT, -- where it came from, null for manual upload
source_url TEXT, -- original page URL (for attribution)
source_id TEXT, -- the site's id for the thing/model
license TEXT,
designer_id INTEGER REFERENCES designers(id) ON DELETE SET NULL,
thumbnail_path TEXT,
print_settings JSONB NOT NULL DEFAULT '{}'::jsonb,
date_added TIMESTAMPTZ NOT NULL DEFAULT now(),
date_modified TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS files (
id SERIAL PRIMARY KEY,
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
file_name TEXT NOT NULL, -- on-disk name (unique suffix)
original_name TEXT NOT NULL, -- name as downloaded/uploaded
file_path TEXT NOT NULL, -- path on disk
format TEXT NOT NULL, -- stl | 3mf | obj | step | gcode | zip | ...
size BIGINT NOT NULL DEFAULT 0,
volume DOUBLE PRECISION, -- cm^3 (from node-stl), when computable
bbox JSONB, -- {x,y,z} bounding box
facets INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS model_projects (
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
PRIMARY KEY (model_id, project_id)
);
CREATE TABLE IF NOT EXISTS model_tags (
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (model_id, tag_id)
);
-- Cached GLB conversion of a 3MF, for in-browser 3D preview.
ALTER TABLE files ADD COLUMN IF NOT EXISTS glb_path TEXT;
-- Whether the model's license permits commercial use (selling). true/false/null.
ALTER TABLE models ADD COLUMN IF NOT EXISTS commercial_use BOOLEAN;
CREATE INDEX IF NOT EXISTS idx_files_model ON files(model_id);
CREATE INDEX IF NOT EXISTS idx_model_projects_p ON model_projects(project_id);
CREATE INDEX IF NOT EXISTS idx_model_tags_t ON model_tags(tag_id);
CREATE INDEX IF NOT EXISTS idx_models_source ON models(source_site, source_id);
-- Content + geometry fingerprints for the duplicate finder (filled in the
-- background by lib/fingerprint.js).
ALTER TABLE files ADD COLUMN IF NOT EXISTS sha256 TEXT;
ALTER TABLE files ADD COLUMN IF NOT EXISTS geometry_hash TEXT;
-- null = not checked yet; ok | error | missing | na (format has no geometry fingerprint)
ALTER TABLE files ADD COLUMN IF NOT EXISTS geometry_status TEXT;
ALTER TABLE files ADD COLUMN IF NOT EXISTS fingerprinted_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_files_sha256 ON files(sha256);
CREATE INDEX IF NOT EXISTS idx_files_geometry ON files(geometry_hash);
-- Free-form notes per model.
ALTER TABLE models ADD COLUMN IF NOT EXISTS notes TEXT NOT NULL DEFAULT '';
-- Soft delete: set when a model is moved to the trash.
ALTER TABLE models ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_models_deleted ON models(deleted_at);
-- Archive files (zip/7z/rar/tar) are indexed in the background:
-- null = not yet, ok | error (with archive_error).
ALTER TABLE files ADD COLUMN IF NOT EXISTS archive_status TEXT;
ALTER TABLE files ADD COLUMN IF NOT EXISTS archive_error TEXT;
-- Files inside archives, so they can be browsed, previewed and matched as duplicates.
CREATE TABLE IF NOT EXISTS archive_entries (
id SERIAL PRIMARY KEY,
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
path TEXT NOT NULL, -- path inside the archive, as the archive stores it
format TEXT NOT NULL,
size BIGINT NOT NULL DEFAULT 0,
sha256 TEXT,
geometry_hash TEXT,
geometry_status TEXT, -- same states as files.geometry_status
fingerprinted_at TIMESTAMPTZ,
UNIQUE (file_id, path)
);
CREATE INDEX IF NOT EXISTS idx_entries_sha256 ON archive_entries(sha256);
CREATE INDEX IF NOT EXISTS idx_entries_geometry ON archive_entries(geometry_hash);
-- Background thumbnails: null = not tried yet, none = nothing to make one from
-- (left to the browser), error. Cleared when files are added.
ALTER TABLE models ADD COLUMN IF NOT EXISTS thumbnail_status TEXT;
-- Duplicate groups marked "not duplicates" (signature = sorted model ids).
CREATE TABLE IF NOT EXISTS duplicate_dismissals (
signature TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Where a model's tag came from: user (typed in), rule (automatic tagging) or ai.
-- Rule and AI passes only add or remove their own tags; yours are never touched.
ALTER TABLE model_tags ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'user';
-- Automatic tagging: fingerprint of the rules and names the rule tags were last
-- worked out from, so a model is re-checked only when one of those changes.
ALTER TABLE models ADD COLUMN IF NOT EXISTS autotag_key TEXT;
-- Semantic search: a local embedding of the model's text, and the fingerprint of
-- the text (and embedding model) it was made from.
ALTER TABLE models ADD COLUMN IF NOT EXISTS embedding REAL[];
ALTER TABLE models ADD COLUMN IF NOT EXISTS embedding_key TEXT;
-- AI tagging runs, kept so they can be watched, stopped and resumed.
ALTER TABLE models ADD COLUMN IF NOT EXISTS ai_tagged_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS ai_runs (
id SERIAL PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'running', -- running | paused | stopped | done | failed
provider TEXT,
model TEXT,
options JSONB NOT NULL DEFAULT '{}', -- { overwriteDescriptions, maxCost, pricesKnown }
total INTEGER NOT NULL DEFAULT 0,
done INTEGER NOT NULL DEFAULT 0,
failed INTEGER NOT NULL DEFAULT 0,
prompt_tokens BIGINT NOT NULL DEFAULT 0,
completion_tokens BIGINT NOT NULL DEFAULT 0,
cost_usd NUMERIC(12, 6) NOT NULL DEFAULT 0,
error TEXT,
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS ai_run_items (
run_id INTEGER NOT NULL REFERENCES ai_runs(id) ON DELETE CASCADE,
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending', -- pending | done | error
error TEXT,
tags TEXT[],
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (run_id, model_id)
);
CREATE INDEX IF NOT EXISTS idx_ai_run_items_status ON ai_run_items(run_id, status);
-- Your own automatic tagging rules: a word or phrase in a name -> a tag.
CREATE TABLE IF NOT EXISTS tag_rules (
id SERIAL PRIMARY KEY,
phrase TEXT NOT NULL,
tag TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (phrase, tag)
);
-- AI renaming: the name a model had before AI first renamed it (for undo), and
-- what each run renamed.
ALTER TABLE models ADD COLUMN IF NOT EXISTS original_name TEXT;
ALTER TABLE ai_run_items ADD COLUMN IF NOT EXISTS renamed_from TEXT;
ALTER TABLE ai_run_items ADD COLUMN IF NOT EXISTS renamed_to TEXT;
-- Collections the AI suggested for a model, waiting for you to accept or dismiss.
-- Dismissed ones are kept so the same idea isn't suggested for that model again.
CREATE TABLE IF NOT EXISTS collection_suggestions (
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending | accepted | dismissed
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_collection_suggestions_model_name ON collection_suggestions(model_id, lower(name));
ALTER TABLE ai_run_items ADD COLUMN IF NOT EXISTS collections TEXT[];
-- MakerWorld collections watched for new models (lib/collectionWatch.js).
CREATE TABLE IF NOT EXISTS watched_collections (
id SERIAL PRIMARY KEY,
site TEXT NOT NULL DEFAULT 'makerworld',
source_id TEXT NOT NULL, -- the collection's id on the site
url TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '', -- its name there, refreshed on each check
project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL, -- where new models land
ai_tag BOOLEAN NOT NULL DEFAULT false,
fetch_files BOOLEAN NOT NULL DEFAULT false, -- download files during a check, not just metadata
interval_hours INTEGER NOT NULL DEFAULT 6,
enabled BOOLEAN NOT NULL DEFAULT true,
last_checked_at TIMESTAMPTZ,
next_check_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_status TEXT, -- ok | error
last_error TEXT,
last_error_reason TEXT, -- makerworld_token: offer the token prompt
last_added INTEGER NOT NULL DEFAULT 0,
listed INTEGER, -- models the page listed at the last check
total INTEGER, -- models the site says the collection holds
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (site, source_id)
);
-- What a watch has already seen, so a model isn't imported (or retried) twice.
CREATE TABLE IF NOT EXISTS watched_collection_items (
watch_id INTEGER NOT NULL REFERENCES watched_collections(id) ON DELETE CASCADE,
design_id TEXT NOT NULL,
model_id INTEGER REFERENCES models(id) ON DELETE SET NULL,
title TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL, -- imported | partial (no files yet) | existing | error
error TEXT,
seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (watch_id, design_id)
);
ALTER TABLE watched_collections ADD COLUMN IF NOT EXISTS fetch_files BOOLEAN NOT NULL DEFAULT false;
-- ------------------------------------------------------------------ filament
-- The spools you own. Weights in grams; remaining is what's left on the spool,
-- null when you haven't said. ams_slot is filled in later by AMS sync, and
-- stays null for spools logged by hand.
CREATE TABLE IF NOT EXISTS filament_spools (
id SERIAL PRIMARY KEY,
material TEXT NOT NULL, -- PLA, PETG, ABS, TPU…
colour TEXT NOT NULL DEFAULT '', -- what you call it, e.g. "Bambu Matte Ivory"
colour_hex TEXT, -- #RRGGBB when known, to match a project's colours
brand TEXT NOT NULL DEFAULT '',
total_g INTEGER, -- spool weight when new
remaining_g INTEGER, -- what's left; null = unknown
ams_slot TEXT, -- "<printer>/<unit>/<slot>" once AMS sync lands
code TEXT, -- maker's filament code (Bambu 10101 etc.), what the AMS reports
notes TEXT NOT NULL DEFAULT '',
date_added TIMESTAMPTZ NOT NULL DEFAULT now(),
date_modified TIMESTAMPTZ
);
-- What a model asks for, read once from its 3MF project rather than on every
-- page: [{ type, colour }]. A background pass fills these in, the way
-- thumbnails and fingerprints are done, because the reader shells out to Python.
ALTER TABLE models ADD COLUMN IF NOT EXISTS filaments JSONB;
ALTER TABLE models ADD COLUMN IF NOT EXISTS filaments_status TEXT; -- pending | done | none | error
ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS code TEXT;
-- A Bambu spool's RFID identity and where it was last seen, so a printer's
-- report finds the same spool again instead of adding it twice.
ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS rfid_uuid TEXT;
ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS tag_uid TEXT;
ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS product_code TEXT;
ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS printer_serial TEXT;
ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ;
CREATE UNIQUE INDEX IF NOT EXISTS filament_spools_rfid ON filament_spools (rfid_uuid) WHERE rfid_uuid IS NOT NULL;
-- ------------------------------------------------------------------ printers
-- The printers you've added. access_code is read by the server only.
CREATE TABLE IF NOT EXISTS printers (
id SERIAL PRIMARY KEY,
nickname TEXT, -- shown instead of the model when set
model TEXT, -- A1, P1S, … or Other
host TEXT,
serial TEXT,
access_code TEXT,
ams_type TEXT, -- none | ams_lite | ams | ams_2_pro | ams_ht
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
date_modified TIMESTAMPTZ
);
-- The one printer that used to live in Settings becomes the first entry here.
INSERT INTO printers (host, serial, access_code)
SELECT h.value, s.value, c.value
FROM settings h
JOIN settings s ON s.key = 'printer_serial'
JOIN settings c ON c.key = 'printer_access_code'
WHERE h.key = 'printer_host'
AND coalesce(h.value, '') <> '' AND coalesce(s.value, '') <> '' AND coalesce(c.value, '') <> ''
AND NOT EXISTS (SELECT 1 FROM printers);
-- …and its copy in Settings goes, so the access code isn't kept twice — but
-- only once that printer is safely here.
DELETE FROM settings
WHERE key IN ('printer_host', 'printer_serial', 'printer_access_code')
AND EXISTS (
SELECT 1 FROM printers p
JOIN settings s ON s.key = 'printer_serial' AND s.value = p.serial
);