feat(sync): notify on enable, change defaults to daily and designer profiles

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-12 04:08:24 +02:00
1 parent acfa844ebe
commit 9334438a52
11 files changed
+60 -15

No files matched your search

@@ -0,0 +1,2 @@
-- New syncs are checked daily. Existing syncs keep their interval.
ALTER TABLE "CollectionSync" ALTER COLUMN "intervalHours" SET DEFAULT 24;
+2 -1
View File
@@ -208,9 +208,10 @@ model CollectionSync {
externalId String
sourceUrl String
// MakerWorld only: which print profiles a new model brings ("url" is the model's default one).
// New MakerWorld syncs start on "designer" (see createSync).
profileScope String @default("url")
// How often the provider's collection is checked: 1, 6 or 24.
intervalHours Int @default(1)
intervalHours Int @default(24)
// Every model id the provider's collection has listed, so a sync only imports what's new since.
knownIds String[] @default([])
// Checks in a row that found the provider's collection gone. One bad answer doesn't unsync it.
@@ -4,7 +4,7 @@
import { COLLECTION_SYNC_ENABLED } from "../config";
/** The intervals a collection can pick, in hours. The shortest is also the default. */
/** The intervals a collection can pick, in hours. New syncs get 24 (see CollectionSync.intervalHours). */
export const SYNC_INTERVAL_HOURS = [1, 6, 24] as const;
export type SyncIntervalHours = (typeof SYNC_INTERVAL_HOURS)[number];
@@ -10,6 +10,7 @@ import { parseMakerworldCollectionUrl } from "./makerworldCollections";
import { parsePrintablesCollectionUrl } from "./printablesApi";
import { parseThingiverseCollectionUrl, parseThingiverseLikesUrl } from "./thingiverseApi";
import { createLog } from "./auditLog";
import { createNotification } from "./notificationService";
import type { SyncIntervalHours } from "./collectionSyncSchedule";
import {
LIKES_ID_PREFIX,
@@ -206,9 +207,38 @@ export async function enableSync(userId: string, input: EnableSyncInput): Promis
targetId: created.collectionId,
details: { name: created.collection.name, provider: source.provider, url: source.url },
});
// The sync exists by now, so a failed notification mustn't fail the request: a retry finds the
// sync and wouldn't send it either.
await notifySyncEnabled(created).catch((err) => console.error("[sync] enable notification failed", err));
return toSyncLinkOut(created);
}
const INTERVAL_PHRASES: Record<number, string> = { 1: "every hour", 6: "every 6 hours", 24: "once a day" };
const PROFILE_SCOPE_PHRASES: Record<string, string> = {
url: "its default print profile",
designer: "all of the designer's print profiles",
all: "all of its print profiles, community ones included",
};
/** Confirms in the app what was switched on from Thingport Grab, with the defaults it got. */
async function notifySyncEnabled(sync: CollectionSync & { collection: Collection }): Promise<void> {
const name = sync.collection.name;
const interval = INTERVAL_PHRASES[sync.intervalHours] ?? `every ${sync.intervalHours} hours`;
const profiles =
sync.provider === "makerworld" && PROFILE_SCOPE_PHRASES[sync.profileScope]
? `, each with ${PROFILE_SCOPE_PHRASES[sync.profileScope]}`
: "";
await createNotification(sync.userId, {
type: "sync",
title: `"${name}" is now synced`,
body:
`New models in ${sourceDescription(sync)} are imported here ${interval}${profiles}. ` +
"Change this in the collection's sync configuration.",
externalUrl: sync.sourceUrl,
internalPath: `/models/collections/${sync.collectionId}`,
});
}
// How many "Things (MakerWorld 2)"-style names to try before giving up.
const MAX_NAME_SUFFIX = 20;
@@ -222,6 +252,9 @@ function* collectionNamesFor(title: string, provider: string): Generator<string>
for (let n = 2; n <= MAX_NAME_SUFFIX; n++) yield `${title} (${label} ${n})`;
}
// A synced collection follows what the designer publishes, so new models bring all of their profiles.
const NEW_MAKERWORLD_SYNC_PROFILE_SCOPE: SyncProfileScope = "designer";
/** Links `collection` to `source`. `takenBy` names the provider when the collection already follows
* another source; `link` is the existing one when another request just made this same link. */
async function createSync(
@@ -241,6 +274,7 @@ async function createSync(
externalId: source.externalId,
sourceUrl: source.url,
knownIds,
...(source.provider === "makerworld" ? { profileScope: NEW_MAKERWORLD_SYNC_PROFILE_SCOPE } : {}),
},
include: { collection: true },
});
+9 -4
View File
@@ -120,7 +120,7 @@ async function importDirectly(designId: string) {
}
async function latestNotification() {
return prisma.notification.findFirstOrThrow({ where: { userId }, orderBy: { createdAt: "desc" } });
return prisma.notification.findFirstOrThrow({ where: { userId }, orderBy: [{ createdAt: "desc" }, { id: "desc" }] });
}
describe("synced collections", () => {
@@ -164,7 +164,12 @@ describe("synced collections", () => {
const list = await request(app).get("/api/collections").set(auth());
const listed = list.body.find((c: { id: string }) => c.id === res.body.collection_id);
expect(listed.sync).toMatchObject({ provider: "makerworld", profile_scope: "url" });
expect(listed.sync).toMatchObject({ provider: "makerworld", profile_scope: "designer", interval_hours: 24 });
const notification = await latestNotification();
expect(notification.title).toBe(`"Phoenix flock" is now synced`);
expect(notification.body).toContain("once a day, each with all of the designer's print profiles");
expect(notification.internalPath).toBe(`/models/collections/${res.body.collection_id}`);
});
it("brings back a deleted collection of already-imported models, in the provider's order", async () => {
@@ -556,13 +561,13 @@ describe("synced collections", () => {
expect(res.status).toBe(400);
});
it("checks every collection on its own interval, 1 hour unless it picks 6 or 24", async () => {
it("checks every collection on its own interval, 24 hours unless it picks 1 or 6", async () => {
const enabled = await enable({
url: `https://www.thingiverse.com/collections/${newId()}`,
title: `Thingiverse ${newId()}`,
known_ids: [],
});
expect(enabled.body.interval_hours).toBe(1);
expect(enabled.body.interval_hours).toBe(24);
const patch = (body: Record<string, unknown>) =>
request(app).patch(`/api/collection/${enabled.body.collection_id}/sync`).set(auth()).send(body);