feat(collections): add many models to a collection in one call

Add POST /collection/:id/items. Thingport Grab uses it to file a
MakerWorld collection's already-imported models instead of one request
per model, falling back on older instances.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-12 03:50:57 +02:00
1 parent 623cf25f2c
commit acfa844ebe
3 files changed
+68 -3

No files matched your search

+32
View File
@@ -245,6 +245,38 @@ router.post(
}),
);
const addItemsSchema = z.object({ print_ids: z.array(z.string()).max(10_000) });
// Many at once, e.g. Thingport Grab filing a provider collection's already-imported models.
router.post(
"/collection/:id/items",
asyncHandler(async (req, res) => {
if (isSystemCollectionId(req.params.id)) {
throw new HttpError(400, "This collection can't be edited");
}
const body = parseBody(addItemsSchema, req.body);
const collection = await prisma.collection.findFirst({ where: { id: req.params.id, userId: req.userId } });
if (!collection) throw new HttpError(404, "Collection not found");
const prints = await prisma.print.findMany({
where: { id: { in: body.print_ids }, userId: req.userId },
select: { id: true },
});
await addPrintsToCollection(
collection.id,
prints.map((p) => p.id),
);
res.json({ ok: true });
if (prints.length) {
void createLog({
userId: req.userId!,
action: "collection_item_added",
targetId: collection.id,
details: { name: collection.name, count: prints.length },
});
}
}),
);
router.post(
"/collection/:id/bookmark",
asyncHandler(async (req, res) => {
+28
View File
@@ -155,6 +155,34 @@ describe("removing one item from a collection", () => {
});
});
describe("adding many items to a collection at once", () => {
it("adds the caller's own prints, skipping unknown ids and ones already in it", async () => {
const uploads = await Promise.all(
["bulk-add-1", "bulk-add-2"].map((name) =>
request(app)
.post("/api/upload")
.set(auth())
.attach("files", tmpFile(`${name}.stl`, `solid ${name} endsolid`)),
),
);
const [first, second] = uploads.map((res) => res.body.prints[0].id as string);
const collection = await request(app).post("/api/collections").set(auth()).send({ name: "Bulk Add" });
await addPrintsToCollection(collection.body.id, [first]);
const res = await request(app)
.post(`/api/collection/${collection.body.id}/items`)
.set(auth())
.send({ print_ids: [first, second, "not-a-print"] });
expect(res.status).toBe(200);
const after = await request(app).get(`/api/collection/${collection.body.id}`).set(auth());
expect(after.body.item_count).toBe(2);
for (const id of [first, second]) await request(app).delete(`/api/print/${id}`).set(auth());
await request(app).delete(`/api/collection/${collection.body.id}`).set(auth());
});
});
describe("collection/print deletion cascades", () => {
it("deleting a collection does not delete the models in it", async () => {
const uploadRes = await request(app)
@@ -244,9 +244,14 @@ async function startGuidedImport(
`Adding ${alreadyImported.length} existing model${alreadyImported.length === 1 ? "" : "s"} to your collection…`,
),
);
for (const entry of alreadyImported) {
if (entry.print_id)
await api("POST", `/collection/${collectionId}/items/${entry.print_id}`).catch(() => undefined);
const printIds = alreadyImported.flatMap((entry) => (entry.print_id ? [entry.print_id] : []));
try {
await api("POST", `/collection/${collectionId}/items`, { print_ids: printIds });
} catch {
// Instances without the bulk endpoint, one model at a time.
for (const printId of printIds) {
await api("POST", `/collection/${collectionId}/items/${printId}`).catch(() => undefined);
}
}
}