feat(notifications): reinvent notifications

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-11 23:20:21 +02:00
1 parent 4d4d2209e6
commit 1408b09c59
40 files changed
+1923 -201

No files matched your search

@@ -0,0 +1,40 @@
-- AlterTable
ALTER TABLE "Notification" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'general',
ADD COLUMN "group" JSONB;
-- Existing notifications get the type their title shows, so they keep a matching icon.
UPDATE "Notification" SET "type" = 'queue' WHERE "title" LIKE '%waiting in the import queue';
UPDATE "Notification" SET "type" = 'consume' WHERE "title" LIKE 'Imported % from the consume folder';
UPDATE "Notification" SET "type" = 'sync'
WHERE "type" = 'general' AND (
"title" LIKE '% synced into "%'
OR "title" LIKE 'Couldn''t sync %'
OR "title" LIKE 'Couldn''t find "%'
OR "title" LIKE 'Syncing "%" is paused'
OR "title" LIKE '"%" is no longer synced'
OR "title" LIKE '"%" is up to date'
OR "title" LIKE '% added to "%'
);
UPDATE "Notification" SET "type" = 'import' WHERE "type" = 'general' AND "title" LIKE 'Imported %';
-- CreateIndex
CREATE INDEX "Notification_createdAt_idx" ON "Notification"("createdAt");
-- A queue's "links waiting" notification is now an ordinary one that nothing edits or deletes.
ALTER TABLE "ImportJob" DROP COLUMN "notificationId";
-- Which system notifications each user was already sent, so they are never sent twice.
-- CreateTable
CREATE TABLE "SystemReceipt" (
"userId" TEXT NOT NULL,
"systemId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SystemReceipt_pkey" PRIMARY KEY ("userId","systemId")
);
-- CreateIndex
CREATE INDEX "SystemReceipt_createdAt_idx" ON "SystemReceipt"("createdAt");
-- AddForeignKey
ALTER TABLE "SystemReceipt" ADD CONSTRAINT "SystemReceipt_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+19 -2
View File
@@ -67,6 +67,7 @@ model User {
collectionSyncs CollectionSync[]
importJobs ImportJob[]
notifications Notification[]
systemReceipts SystemReceipt[]
logs Log[]
authorLinks AuthorLink[]
bookmarks Bookmark[]
@@ -299,8 +300,6 @@ model ImportJob {
resultPrintId String?
// The LINKS job's shared request body (never the MakerWorld cookie), so a retry can rerun it.
payload Json?
// The "N links waiting" notification of a PAUSED queue, updated as links are added to it.
notificationId String?
// Set on the LINKS jobs a collection sync queues. They pause on a rate limit instead of failing
// the rest, and are kept out of the extension's "Add to the queue" job.
collectionSyncId String?
@@ -342,6 +341,10 @@ model Notification {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// What it's about, for its icon: import, sync, queue, consume, ai, system or general.
type String @default("general")
/// Set on notifications later ones may fold into, e.g. { key: "single-import", items: [...] }.
group Json?
title String
body String?
externalUrl String?
@@ -351,6 +354,20 @@ model Notification {
@@index([userId, readAt])
@@index([userId, createdAt])
// For the daily cleanup of old notifications.
@@index([createdAt])
}
/// Which system notifications (from the repo's system-notifications.json) a user was already sent,
/// so deleting or pruning the notification never brings it back.
model SystemReceipt {
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
systemId String
createdAt DateTime @default(now())
@@id([userId, systemId])
@@index([createdAt])
}
/// A creator on a source platform. `id` is provider-prefixed ("makerworld:3582000380") so
+6
View File
@@ -124,4 +124,10 @@ export const FLARESOLVERR_URL = (process.env.FLARESOLVERR_URL || "").trim();
export const FLARESOLVERR_TIMEOUT_MS = envInt("FLARESOLVERR_TIMEOUT_MS", 60000);
export const FLARESOLVERR_SESSION_TTL_MS = envInt("FLARESOLVERR_SESSION_TTL_MS", 15 * 60 * 1000);
// Announcements for admins, e.g. known bugs. Set to an empty value to stop fetching them.
export const SYSTEM_NOTIFICATIONS_URL = (
process.env.SYSTEM_NOTIFICATIONS_URL ??
"https://raw.githubusercontent.com/TautvydasDerzinskas/Thingport/main/system-notifications.json"
).trim();
export const API_PORT = envInt("API_PORT", 8000);
+4
View File
@@ -534,22 +534,26 @@ export function toAdminImportJobOut(row: QueueJobRow): AdminImportJobOut {
export type NotificationOut = {
id: string;
type: string;
title: string;
body: string | null;
external_url: string | null;
internal_path: string | null;
read: boolean;
read_at: string | null;
created_at: string;
};
export function toNotificationOut(notification: Notification): NotificationOut {
return {
id: notification.id,
type: notification.type,
title: notification.title,
body: notification.body,
external_url: notification.externalUrl,
internal_path: notification.internalPath,
read: notification.readAt !== null,
read_at: notification.readAt?.toISOString() ?? null,
created_at: notification.createdAt.toISOString(),
};
}
+55 -1
View File
@@ -4,6 +4,7 @@ import { Router } from "express";
import { z } from "zod";
import { requireAuth } from "../auth";
import { HttpError } from "../utils/fileUtils";
import { prisma } from "../db";
import { normalizeImportUrl } from "../utils/urlUtils";
import { parseBody } from "../utils/validate";
import { asyncHandler } from "../utils/asyncHandler";
@@ -52,6 +53,7 @@ import {
runZipImportJob,
} from "../services/importJobRunner";
import { createLog } from "../services/auditLog";
import { notifyImportRun, notifySingleImport } from "../services/modelNotifications";
import { toImportJobItemOut, toImportJobOut, toPrintOut } from "../dto";
import type { Prisma } from "@prisma/client";
@@ -71,6 +73,8 @@ const importRequestSchema = z.object({
resolved_download_url: z.string().nullable().optional(),
resolved_instance_id: z.string().nullable().optional(),
makerworld_design: z.record(z.unknown()).nullable().optional(),
// False for each model of a run the client drives itself; the run sends one summary at the end.
notify: z.boolean().optional(),
});
// Falls back to the cookie saved in Settings when the request doesn't carry one.
@@ -84,10 +88,26 @@ async function withStoredMakerworldCookie<T extends { makerworld_cookie?: string
}
async function importAndDescribe(userId: string, url: string, body: z.infer<typeof importRequestSchema>) {
const result = await importPrintFromUrl(userId, url, body);
let result;
try {
result = await importPrintFromUrl(userId, url, body);
} catch (err) {
if (body.notify !== false) {
const error = err instanceof HttpError ? err.message : "Something went wrong on the server.";
await notifySingleImport(userId, url, { error }).catch((e) => console.error("Import notification failed:", e));
}
throw err;
}
const { print, plates, author, previewImages } = result;
const importOutcome = result.alreadyImported ? "already_imported" : result.profileAdded ? "profile_added" : "created";
void createLog({ userId, action: "model_imported", targetId: print.id, details: { name: print.name, url } });
if (body.notify !== false) {
await notifySingleImport(userId, url, {
printId: print.id,
name: print.title || print.name,
outcome: importOutcome,
}).catch((e) => console.error("Import notification failed:", e));
}
return { ...toPrintOut(print, plates, [], null, author, previewImages), import_outcome: importOutcome };
}
@@ -107,6 +127,40 @@ router.post(
}),
);
// The end of a run the client drove one model at a time (the extension's guided MakerWorld import).
const importSummarySchema = z.object({
source_url: z.string().url(),
total: z.number().int().min(1).max(10_000),
print_ids: z.array(z.string()).max(10_000),
collection_id: z.string().nullable().optional(),
// Why the run stopped early, if it did.
stopped: z.string().max(500).nullable().optional(),
});
router.post(
"/import/summary",
asyncHandler(async (req, res) => {
const body = parseBody(importSummarySchema, req.body);
const prints = await prisma.print.findMany({
where: { id: { in: body.print_ids }, userId: req.userId! },
select: { id: true, name: true, title: true },
});
const collection = body.collection_id
? await prisma.collection.findFirst({
where: { id: body.collection_id, userId: req.userId! },
select: { id: true },
})
: null;
await notifyImportRun(req.userId!, {
sourceUrl: body.source_url,
total: body.total,
prints: prints.map((p) => ({ id: p.id, name: p.title || p.name })),
collectionId: collection?.id ?? null,
stopped: body.stopped ?? null,
});
res.json({ ok: true });
}),
);
router.get(
"/import/tasks/:id",
asyncHandler(async (req, res) => {
+43 -4
View File
@@ -1,24 +1,63 @@
import { Router } from "express";
import { z } from "zod";
import { requireAuth } from "../auth";
import { asyncHandler } from "../utils/asyncHandler";
import { listNotifications, markAllRead } from "../services/notificationService";
import { HttpError } from "../utils/fileUtils";
import { parseBody } from "../utils/validate";
import {
MAX_PAGE_SIZE,
NOTIFICATION_FILTERS,
deleteAllNotifications,
deleteNotification,
listNotifications,
markAllRead,
} from "../services/notificationService";
import { toNotificationOut } from "../dto";
const router = Router();
router.use(requireAuth);
const listQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).optional(),
before: z.string().optional(),
filter: z.enum(NOTIFICATION_FILTERS).optional(),
since: z.coerce.date().optional(),
});
router.get(
"/notifications",
asyncHandler(async (req, res) => {
const { items, unreadCount } = await listNotifications(req.userId!);
res.json({ items: items.map(toNotificationOut), unread_count: unreadCount });
const query = parseBody(listQuerySchema, req.query);
const { items, unreadCount, nextCursor } = await listNotifications(req.userId!, query);
res.json({ items: items.map(toNotificationOut), unread_count: unreadCount, next_cursor: nextCursor });
}),
);
router.post(
"/notifications/read-all",
asyncHandler(async (req, res) => {
await markAllRead(req.userId!);
const readAt = await markAllRead(req.userId!);
res.json({ ok: true, read_at: readAt.toISOString() });
}),
);
router.delete(
"/notifications/:id",
asyncHandler(async (req, res) => {
if (!(await deleteNotification(req.userId!, req.params.id))) throw new HttpError(404, "Notification not found");
res.json({ ok: true });
}),
);
// With a filter, only that tab's notifications.
const deleteQuerySchema = z.object({
filter: z.enum(NOTIFICATION_FILTERS).optional(),
since: z.coerce.date().optional(),
});
router.delete(
"/notifications",
asyncHandler(async (req, res) => {
const { filter, since } = parseBody(deleteQuerySchema, req.query);
await deleteAllNotifications(req.userId!, filter, since);
res.json({ ok: true });
}),
);
+46
View File
@@ -28,6 +28,7 @@ import { estimateDownloadSize, resolvePrintsForDownload, sendPrintsZip } from ".
import { systemCollectionKeyForId } from "../services/collectionService";
import { createLog } from "../services/auditLog";
import { recordActivity } from "../services/activityService";
import { notifyUpload } from "../services/modelNotifications";
import { fillSourceGaps } from "../services/fillGapsService";
import { isNormalizable3mf, normalize3mfStatus, normalized3mfFor } from "../services/normalized3mfCache";
import type { Prisma } from "@prisma/client";
@@ -110,6 +111,11 @@ router.post(
asyncHandler(async (req, res) => {
const files = (req.files as Express.Multer.File[]) || [];
const body = req.body as Record<string, string | undefined>;
// A browser uploading a batch one request at a time sends notify=false, then one summary at the end.
const notify = body.notify !== "false";
// What made it in before any failure, so a partly failed request reports both halves truthfully.
const created: { id: string; name: string }[] = [];
const pending = new Set(files);
try {
if (!files.length) throw new HttpError(400, "No files uploaded");
const mode = body.mode === "multiplate" ? "multiplate" : "separate";
@@ -137,6 +143,8 @@ router.post(
{ filename: safeName, mime, tempFilePath: file.path },
]);
printsOut.push(toPrintOut(print, plates, [], null));
created.push({ id: print.id, name: print.title || print.name });
pending.delete(file);
void recordActivity(req.userId!, "upload", print.id, print.name);
void createLog({
userId: req.userId!,
@@ -158,9 +166,12 @@ router.post(
});
const nameHint = path.parse(plateInputs[0].filename).name;
const { print } = await createPrint(req.userId!, meta, nameHint, plateInputs);
created.push({ id: print.id, name: print.title || print.name });
for (const f of plateFiles) pending.delete(f);
for (const f of supportingFiles) {
await saveFileFromTemp(req.userId!, print.id, f.path, f.originalname, f.mimetype);
pending.delete(f);
}
printsOut.push(await printOutById(req.userId!, print.id));
@@ -172,7 +183,18 @@ router.post(
details: { name: print.name },
});
}
if (notify) {
await notifyUpload(req.userId!, created).catch((err) => console.error("Upload notification failed:", err));
}
res.json({ prints: printsOut });
} catch (err) {
if (notify && files.length) {
await notifyUpload(req.userId!, created, {
names: [...pending].map((f) => sanitizeFilename(f.originalname)),
reason: err instanceof HttpError ? err.message : undefined,
}).catch((e) => console.error("Upload notification failed:", e));
}
throw err;
} finally {
for (const f of files) {
if (fs.existsSync(f.path)) fs.rmSync(f.path, { force: true });
@@ -181,6 +203,30 @@ router.post(
}),
);
// The end of a batch the browser uploaded one request at a time, each sent with notify=false.
const uploadSummarySchema = z.object({
print_ids: z.array(z.string()).max(1000),
failed: z.array(z.string().max(300)).max(1000).default([]),
});
router.post(
"/upload/summary",
asyncHandler(async (req, res) => {
const body = parseBody(uploadSummarySchema, req.body);
// Only the caller's own models, by their current names.
const prints = await prisma.print.findMany({
where: { id: { in: body.print_ids }, userId: req.userId! },
select: { id: true, name: true, title: true, createdAt: true },
orderBy: { createdAt: "asc" },
});
await notifyUpload(
req.userId!,
prints.map((p) => ({ id: p.id, name: p.title || p.name })),
{ names: body.failed },
);
res.json({ ok: true });
}),
);
type PrintSortMode = "newest" | "popular" | "downloads";
function parseSortMode(raw: unknown): PrintSortMode {
+4
View File
@@ -5,6 +5,8 @@ import { startConsumeWatcher } from "./services/consumeService";
import { startCollectionSyncScheduler } from "./services/collectionSyncRunner";
import { SYNC_JOB_INTERRUPTED } from "./services/importJobRunner";
import { seedFlaresolverrUrl } from "./services/settingsService";
import { startNotificationCleanup } from "./services/notificationService";
import { startSystemNotificationSync } from "./services/systemNotificationService";
const app = createApp();
@@ -27,4 +29,6 @@ app.listen(API_PORT, () => {
console.log(`Thingport API listening on port ${API_PORT}`);
startConsumeWatcher();
startCollectionSyncScheduler();
startNotificationCleanup();
startSystemNotificationSync();
});
@@ -258,6 +258,7 @@ async function runSync(syncId: string): Promise<SyncOutcome> {
async function notifyFiled(sync: SyncWithCollection, printIds: string[]): Promise<void> {
const n = printIds.length;
await createNotification(sync.userId, {
type: "sync",
title: `${n} model${n === 1 ? "" : "s"} added to "${sync.collection.name}"`,
body: syncLikesUsername(sync)
? `${n === 1 ? "It was" : "They were"} liked on Thingiverse and already in your library.`
@@ -279,6 +280,7 @@ async function unsyncGone(sync: SyncWithCollection): Promise<void> {
details: { name, provider: sync.provider, url: sync.sourceUrl, reason: "source_gone" },
});
await createNotification(sync.userId, {
type: "sync",
title: `"${name}" is no longer synced`,
body: `${capitalize(sourceDescription(sync))} can't be found any more, so new models won't come in. The models already in "${name}" stay.`,
externalUrl: sync.sourceUrl,
@@ -337,6 +339,7 @@ async function notifyManualOutcome(before: SyncWithCollection, outcome: SyncOutc
const note = message[outcome];
if (!note) return;
await createNotification(before.userId, {
type: "sync",
...note,
externalUrl: before.sourceUrl,
internalPath: `/models/collections/${before.collectionId}`,
+1
View File
@@ -343,6 +343,7 @@ function consumeNotification({ models, files, failed, printIds }: ConsumeSummary
);
}
return {
type: "consume" as const,
title: `Imported ${plural(models, "model")} from the consume folder`,
body: bodyParts.join(" "),
internalPath: models === 1 ? `/models/${printIds[0]}` : "/models",
+8
View File
@@ -187,6 +187,7 @@ export async function runMakerworldProfilesImportJob(
}
const label = title ? `"${title}"` : "a MakerWorld model";
await createNotification(userId, {
type: "import",
title: `Imported ${imported} of ${profileIds.length} print profiles from MakerWorld`,
body: bodyParts.length ? `Of ${label} — ${bodyParts.join(", ")}.` : `Of ${label}.`,
externalUrl: body.url,
@@ -317,6 +318,7 @@ export async function runCollectionImportJob(
);
if (otherFailed) bodyParts.push(`${otherFailed} failed`);
await createNotification(userId, {
type: "import",
title: `Imported ${imported} of ${body.design_ids.length} models from MakerWorld`,
body: bodyParts.length ? `From ${label} — ${bodyParts.join(", ")}.` : `From ${label}.`,
externalUrl: body.url,
@@ -428,6 +430,7 @@ async function runThingiverseThingsImportJob(
if (otherFailed) bodyParts.push(`${otherFailed} failed`);
const label = sourceLabel(collectionTitle);
await createNotification(userId, {
type: "import",
title: `Imported ${imported} of ${body.thing_ids.length} models from Thingiverse`,
body: bodyParts.length ? `From ${label} — ${bodyParts.join(", ")}.` : `From ${label}.`,
externalUrl: body.url,
@@ -549,6 +552,7 @@ export async function runPrintablesCollectionImportJob(
if (rateLimited) bodyParts.push(`${rateLimited} rate-limited by Printables — wait a while, then retry`);
if (otherFailed) bodyParts.push(`${otherFailed} failed`);
await createNotification(userId, {
type: "import",
title: `Imported ${imported} of ${body.model_ids.length} models from Printables`,
body: bodyParts.length ? `From "${collectionTitle}" — ${bodyParts.join(", ")}.` : `From "${collectionTitle}".`,
externalUrl: body.url,
@@ -605,6 +609,7 @@ export async function runZipImportJob(jobId: string, userId: string, body: ZipIm
const bodyParts: string[] = [];
if (failed.length) bodyParts.push(`${failed.length} failed`);
await createNotification(userId, {
type: "import",
title: `Imported ${prints.length} of ${body.entries.length} models from ${filename}`,
body: bodyParts.length ? `${bodyParts.join(", ")}.` : null,
externalUrl: body.url,
@@ -872,6 +877,7 @@ async function runLinks(jobId: string, userId: string, body: LinksImportJobBody)
bodyParts.push(`${failedCount} failed — retry them from the import's progress bar`);
}
await createNotification(userId, {
type: "import",
title: `Imported ${totalImported} of ${total} models`,
body: bodyParts.length ? `${bodyParts.join(", ")}.` : null,
externalUrl: job.sourceUrl,
@@ -949,6 +955,7 @@ async function pauseSyncJob(job: ImportJob, reason: StopReason, newPrintIds: str
: "An admin can start it again from the import queue once that clears.",
);
await createNotification(job.userId, {
type: "sync",
title: `Syncing "${name}" is paused`,
body: parts.join(" "),
externalUrl: sync?.sourceUrl ?? job.sourceUrl,
@@ -982,6 +989,7 @@ async function notifySyncJobDone(
}
const count = newPrintIds.length;
await createNotification(job.userId, {
type: "sync",
title: count
? `${count} new model${count === 1 ? "" : "s"} synced into "${name}"`
: `Couldn't sync new models into "${name}"`,
+16 -46
View File
@@ -4,7 +4,9 @@
import type { ImportJob, Prisma } from "@prisma/client";
import { prisma } from "../db";
import { createNotification } from "./notificationService";
import { HttpError } from "../utils/fileUtils";
import { createKeyedLock } from "../utils/concurrency";
import { getActiveJob, resetFailedItems } from "./importJobService";
import {
isRunnerLive,
@@ -89,47 +91,21 @@ export async function pauseLinksJob(job: ImportJob): Promise<void> {
// ---- Sending links to the queue (the extension) ----
// Two quick sends from the extension would otherwise each create a PAUSED job.
const queueLocks = new Map<string, Promise<unknown>>();
const withUserLock = createKeyedLock();
function withUserLock<T>(userId: string, fn: () => Promise<T>): Promise<T> {
const previous = queueLocks.get(userId) ?? Promise.resolve();
const next = previous.catch(() => undefined).then(fn);
queueLocks.set(userId, next);
void next.finally(() => {
if (queueLocks.get(userId) === next) queueLocks.delete(userId);
});
return next;
}
function waitingTitle(count: number): string {
return count === 1 ? "1 link waiting in the import queue" : `${count} links waiting in the import queue`;
}
/** One notification per PAUSED queue, kept current: each new link refreshes its count and marks it
* unread again instead of stacking a notification per link. */
async function refreshQueueNotification(jobId: string): Promise<void> {
const job = await prisma.importJob.findUnique({ where: { id: jobId }, include: { user: true } });
if (!job || job.status !== "PAUSED") return;
const waiting = await prisma.importJobItem.count({ where: { jobId, status: { in: ["PENDING", "FAILED"] } } });
const isAdmin = job.user.role === "ADMIN";
const data = {
title: waitingTitle(waiting),
/** Sent once, when links start collecting in a new paused queue. The queue shows its own live count;
* this notification is never edited or removed with it. */
async function notifyQueueStarted(userId: string): Promise<void> {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { role: true } });
const isAdmin = user?.role === "ADMIN";
await createNotification(userId, {
type: "queue",
title: "Links are waiting in the import queue",
body: isAdmin
? "Paused until you start it from Administration > Import queue."
: "Paused until an admin starts it from the import queue.",
internalPath: isAdmin ? "/admin-queue" : null,
readAt: null,
createdAt: new Date(),
};
const existing = job.notificationId
? await prisma.notification.findFirst({ where: { id: job.notificationId, userId: job.userId } })
: null;
if (existing) {
await prisma.notification.update({ where: { id: existing.id }, data });
return;
}
const created = await prisma.notification.create({ data: { ...data, userId: job.userId } });
await prisma.importJob.update({ where: { id: jobId }, data: { notificationId: created.id } });
});
}
export type QueueLinkResult = { job: ImportJob; itemId: string; duplicate: boolean; waiting: number };
@@ -158,6 +134,7 @@ export async function queueLink(userId: string, url: string, options: QueuedLink
payload: { notes: null, tags: [], category_id: null, scope: "url" },
},
});
await notifyQueueStarted(userId);
}
const existing = await prisma.importJobItem.findFirst({
@@ -179,7 +156,6 @@ export async function queueLink(userId: string, url: string, options: QueuedLink
data: { total: await prisma.importJobItem.count({ where: { jobId: job.id } }) },
});
}
await refreshQueueNotification(job.id);
const waiting = await prisma.importJobItem.count({ where: { jobId: job.id, status: "PENDING" } });
return { job, itemId, duplicate: Boolean(existing), waiting };
});
@@ -233,20 +209,15 @@ function assertIdle(job: ImportJob): void {
}
}
/** A deleted queue's "N links waiting" notification would point at nothing. A started queue's
* stays: it's the history the completion notification follows. */
async function deleteJobAndWaitingNotification(job: ImportJob): Promise<void> {
async function deleteJob(job: ImportJob): Promise<void> {
bulkQueue = bulkQueue.filter((entry) => entry.jobId !== job.id);
await prisma.importJob.delete({ where: { id: job.id } });
if (job.status === "PAUSED" && job.notificationId) {
await prisma.notification.deleteMany({ where: { id: job.notificationId, userId: job.userId } });
}
}
export async function deleteQueueJob(jobId: string): Promise<void> {
const job = await getQueueJob(jobId);
assertIdle(job);
await deleteJobAndWaitingNotification(job);
await deleteJob(job);
}
/** Drops one link from an idle job. A job left without links is deleted with it. */
@@ -257,7 +228,7 @@ export async function removeQueueItem(itemId: string): Promise<{ jobDeleted: boo
await prisma.importJobItem.delete({ where: { id: itemId } });
const left = await prisma.importJobItem.count({ where: { jobId: item.jobId } });
if (!left) {
await deleteJobAndWaitingNotification(item.job);
await deleteJob(item.job);
return { jobDeleted: true };
}
await prisma.importJob.update({
@@ -268,7 +239,6 @@ export async function removeQueueItem(itemId: string): Promise<{ jobDeleted: boo
failedCount: await prisma.importJobItem.count({ where: { jobId: item.jobId, status: "FAILED" } }),
},
});
await refreshQueueNotification(item.jobId);
return { jobDeleted: false };
}
+158
View File
@@ -0,0 +1,158 @@
import { prisma } from "../db";
import { createKeyedLock } from "../utils/concurrency";
import { createNotification } from "./notificationService";
/** Lists names for a notification body, shortening long lists to the first few. */
export function nameList(names: string[], max = 5): string {
const quoted = names.map((name) => `"${name}"`);
if (quoted.length <= max) return quoted.join(", ");
return `${quoted.slice(0, max).join(", ")} and ${quoted.length - max} more`;
}
export type ImportOutcome = "created" | "profile_added" | "already_imported";
const MERGE_WINDOW_MS = 10 * 60 * 1000;
const SINGLE_IMPORT_GROUP = "single-import";
const MAX_GROUP_ITEMS = 200;
type ImportGroup = { key: typeof SINGLE_IMPORT_GROUP; items: { id: string; name: string }[] };
/** The stored JSON is checked, not trusted: anything malformed just starts a new notification. */
function readImportGroup(value: unknown): ImportGroup | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const { key, items } = value as Record<string, unknown>;
if (key !== SINGLE_IMPORT_GROUP || !Array.isArray(items)) return null;
const valid = items.filter(
(item): item is { id: string; name: string } =>
Boolean(item) && typeof item.id === "string" && typeof item.name === "string",
);
return { key, items: valid };
}
// Two imports finishing together would otherwise both miss, or both rewrite, the same notification.
const withUserLock = createKeyedLock();
// Single imports notify whether or not anyone is still waiting: a closed tab or extension, or a proxy
// timing out, would otherwise lose the result.
export async function notifySingleImport(
userId: string,
url: string,
result: { printId: string; name: string; outcome: ImportOutcome } | { error: string },
): Promise<void> {
if ("error" in result) {
await createNotification(userId, {
type: "import",
title: "Couldn't import a model",
body: result.error,
externalUrl: url,
});
return;
}
if (result.outcome === "already_imported") {
await createNotification(userId, {
type: "import",
title: `"${result.name}" is already in your library`,
externalUrl: url,
internalPath: `/models/${result.printId}`,
});
return;
}
const item = { id: result.printId, name: result.name };
await withUserLock(userId, async () => {
// Imports one after another fold into the newest notification while it's unread and recent, and
// nothing else has come in since.
const latest = await prisma.notification.findFirst({
where: { userId },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
const group = readImportGroup(latest?.group);
if (latest && group && !latest.readAt && Date.now() - latest.createdAt.getTime() < MERGE_WINDOW_MS) {
const items = [...group.items.filter((i) => i.id !== item.id), item].slice(-MAX_GROUP_ITEMS);
await prisma.notification.update({
where: { id: latest.id },
data: {
title: items.length === 1 ? latest.title : `Imported ${items.length} models`,
body: items.length === 1 ? latest.body : `${nameList(items.map((i) => i.name))}.`,
externalUrl: items.length === 1 ? latest.externalUrl : null,
internalPath: items.length === 1 ? latest.internalPath : "/models",
group: { key: SINGLE_IMPORT_GROUP, items },
},
});
return;
}
await createNotification(userId, {
type: "import",
title:
result.outcome === "profile_added"
? `Added another print profile to "${result.name}"`
: `Imported "${result.name}"`,
externalUrl: url,
internalPath: `/models/${result.printId}`,
group: { key: SINGLE_IMPORT_GROUP, items: [item] },
});
});
}
/** One notification for a whole upload: a single request's, or a browser batch's summary. */
export async function notifyUpload(
userId: string,
uploaded: { id: string; name: string }[],
failed: { names: string[]; reason?: string } = { names: [] },
): Promise<void> {
const total = uploaded.length + failed.names.length;
if (!total) return;
if (!uploaded.length) {
await createNotification(userId, {
type: "upload",
title: total === 1 ? "Couldn't upload a model" : `Couldn't upload ${total} models`,
body: [nameList(failed.names), failed.reason].filter(Boolean).join(": "),
});
return;
}
const title =
uploaded.length === 1 && !failed.names.length
? `Uploaded "${uploaded[0].name}"`
: failed.names.length
? `Uploaded ${uploaded.length} of ${total} models`
: `Uploaded ${uploaded.length} models`;
const parts: string[] = [];
if (uploaded.length > 1) parts.push(`${nameList(uploaded.map((p) => p.name))}.`);
if (failed.names.length) {
const reason = failed.reason ? `: ${failed.reason.replace(/\.$/, "")}` : "";
parts.push(`Couldn't upload ${nameList(failed.names)}${reason}.`);
}
await createNotification(userId, {
type: "upload",
title,
body: parts.join(" ") || null,
internalPath: uploaded.length === 1 ? `/models/${uploaded[0].id}` : "/models",
});
}
/** A client-driven run of single imports, summed up once at its end. */
export async function notifyImportRun(
userId: string,
run: {
sourceUrl: string;
total: number;
prints: { id: string; name: string }[];
collectionId: string | null;
stopped: string | null;
},
): Promise<void> {
const imported = run.prints.length;
const parts: string[] = [];
if (imported) parts.push(`${nameList(run.prints.map((p) => p.name))}.`);
if (run.stopped) parts.push(`Stopped early: ${run.stopped}`);
await createNotification(userId, {
type: "import",
title: `Imported ${imported} of ${run.total} models from MakerWorld`,
body: parts.join(" ") || null,
externalUrl: run.sourceUrl,
internalPath: run.collectionId
? `/models/collections/${run.collectionId}`
: imported === 1
? `/models/${run.prints[0].id}`
: null,
});
}
+101 -12
View File
@@ -1,37 +1,126 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "../db";
const LIST_LIMIT = 50;
export const NOTIFICATION_TYPES = ["import", "upload", "sync", "queue", "consume", "ai", "system", "general"] as const;
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
/** The tabs: everything, the unread ones, or only system announcements. Opening the list marks
* everything read, so "unread" also takes the ones read since `since`: what was new at that moment. */
export const NOTIFICATION_FILTERS = ["all", "unread", "system"] as const;
export type NotificationFilter = (typeof NOTIFICATION_FILTERS)[number];
function filterWhere(filter: NotificationFilter = "all", since?: Date | null): Prisma.NotificationWhereInput {
if (filter === "unread") return since ? { OR: [{ readAt: null }, { readAt: { gte: since } }] } : { readAt: null };
if (filter === "system") return { type: "system" };
return {};
}
export const DEFAULT_PAGE_SIZE = 20;
export const MAX_PAGE_SIZE = 50;
// Read notifications go after a month; unread ones are kept longer, so nothing unseen disappears
// while someone is away.
const READ_RETENTION_DAYS = 30;
export const UNREAD_RETENTION_DAYS = 90;
const CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
export async function createNotification(
userId: string,
data: { title: string; body?: string | null; externalUrl?: string | null; internalPath?: string | null },
data: {
type: NotificationType;
title: string;
body?: string | null;
externalUrl?: string | null;
internalPath?: string | null;
group?: Prisma.InputJsonValue;
},
): Promise<void> {
await prisma.notification.create({
data: {
userId,
type: data.type,
title: data.title,
body: data.body ?? null,
externalUrl: data.externalUrl ?? null,
internalPath: data.internalPath ?? null,
...(data.group !== undefined ? { group: data.group } : {}),
},
});
}
export async function listNotifications(userId: string) {
const [items, unreadCount] = await Promise.all([
// The cursor is the last item's time and id, so deleting notifications between pages can't break it.
function encodeCursor(item: { createdAt: Date; id: string }): string {
return `${item.createdAt.toISOString()}|${item.id}`;
}
function cursorWhere(cursor: string | null | undefined): Prisma.NotificationWhereInput {
if (!cursor) return {};
const [iso, id] = cursor.split("|");
const createdAt = new Date(iso);
if (!id || Number.isNaN(createdAt.getTime())) return {};
return { OR: [{ createdAt: { lt: createdAt } }, { createdAt, id: { lt: id } }] };
}
/** Newest first, one page at a time. `nextCursor` is null on the last page. */
export async function listNotifications(
userId: string,
options: { limit?: number; before?: string | null; filter?: NotificationFilter; since?: Date | null } = {},
) {
const limit = Math.min(MAX_PAGE_SIZE, Math.max(1, options.limit ?? DEFAULT_PAGE_SIZE));
const [rows, unreadCount] = await Promise.all([
prisma.notification.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
take: LIST_LIMIT,
where: { userId, ...filterWhere(options.filter, options.since), ...cursorWhere(options.before) },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: limit + 1,
}),
prisma.notification.count({ where: { userId, readAt: null } }),
]);
return { items, unreadCount };
const items = rows.slice(0, limit);
const nextCursor = rows.length > limit ? encodeCursor(items[items.length - 1]) : null;
return { items, unreadCount, nextCursor };
}
export async function markAllRead(userId: string): Promise<void> {
await prisma.notification.updateMany({
where: { userId, readAt: null },
data: { readAt: new Date() },
/** Returns the time stamped on them, for the "unread" tab's `since`. */
export async function markAllRead(userId: string): Promise<Date> {
const readAt = new Date();
await prisma.notification.updateMany({ where: { userId, readAt: null }, data: { readAt } });
return readAt;
}
/** False when there was no such notification for this user. */
export async function deleteNotification(userId: string, id: string): Promise<boolean> {
const { count } = await prisma.notification.deleteMany({ where: { id, userId } });
return count > 0;
}
export async function deleteAllNotifications(
userId: string,
filter: NotificationFilter = "all",
since?: Date | null,
): Promise<void> {
await prisma.notification.deleteMany({ where: { userId, ...filterWhere(filter, since) } });
}
export async function pruneNotifications(now = new Date()): Promise<number> {
const { count } = await prisma.notification.deleteMany({
where: {
OR: [
{ readAt: { not: null }, createdAt: { lt: new Date(now.getTime() - READ_RETENTION_DAYS * DAY_MS) } },
{ readAt: null, createdAt: { lt: new Date(now.getTime() - UNREAD_RETENTION_DAYS * DAY_MS) } },
],
},
});
// System notifications older than the unread limit are never sent again, so their receipts can go too.
await prisma.systemReceipt.deleteMany({
where: { createdAt: { lt: new Date(now.getTime() - UNREAD_RETENTION_DAYS * DAY_MS) } },
});
return count;
}
/** Prunes once at startup, then daily. */
export function startNotificationCleanup(): void {
const run = () => pruneNotifications().catch((err) => console.error("Failed to delete old notifications:", err));
void run();
setInterval(run, CLEANUP_INTERVAL_MS).unref();
}
@@ -0,0 +1,107 @@
import { z } from "zod";
import { prisma } from "../db";
import { SYSTEM_NOTIFICATIONS_URL } from "../config";
import { UNREAD_RETENTION_DAYS } from "./notificationService";
import { createKeyedLock } from "../utils/concurrency";
const FETCH_TIMEOUT_MS = 15_000;
const SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
const entrySchema = z.object({
// Never reuse an id: it's what stops an entry from being sent twice.
id: z.string().trim().min(1).max(100),
title: z.string().trim().min(1).max(200),
body: z.string().trim().max(2000).nullish(),
// https only: an announcement's link goes in front of every admin of every instance.
url: z
.string()
.url()
.refine((value) => value.startsWith("https://"))
.nullish(),
published_at: z.coerce.date(),
// Admins by default: most announcements are about running the instance.
audience: z.enum(["admins", "all"]).default("admins"),
});
export type SystemNotificationEntry = z.infer<typeof entrySchema>;
/** One bad entry is skipped rather than hiding the rest. */
export function parseSystemNotifications(data: unknown): SystemNotificationEntry[] {
const list = (data as { notifications?: unknown } | null)?.notifications;
if (!Array.isArray(list)) return [];
return list.flatMap((raw) => {
const parsed = entrySchema.safeParse(raw);
return parsed.success ? [parsed.data] : [];
});
}
async function fetchSystemNotifications(url: string): Promise<SystemNotificationEntry[] | null> {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
if (!res.ok) return null;
return parseSystemNotifications(await res.json());
} catch {
return null;
}
}
const WRITE_BATCH = 1000;
// Runs never overlap: the receipts read up front are what keeps an entry from being sent twice.
const withDeliveryLock = createKeyedLock();
/** Sends each user every entry meant for them they haven't had yet, as an ordinary notification dated
* when it was posted. Future entries wait for their date; ones past the unread retention aren't sent. */
export function deliverSystemNotifications(entries: SystemNotificationEntry[], now = new Date()): Promise<number> {
return withDeliveryLock("system", async () => {
const oldest = now.getTime() - UNREAD_RETENTION_DAYS * DAY_MS;
const due = entries.filter((e) => e.published_at.getTime() <= now.getTime() && e.published_at.getTime() >= oldest);
if (!due.length) return 0;
const [users, receipts] = await Promise.all([
prisma.user.findMany({ select: { id: true, role: true } }),
prisma.systemReceipt.findMany({
where: { systemId: { in: due.map((e) => e.id) } },
select: { userId: true, systemId: true },
}),
]);
const seen = new Set(receipts.map((r) => `${r.userId}\n${r.systemId}`));
const toSend = users.flatMap((user) =>
due
.filter((e) => (e.audience === "all" || user.role === "ADMIN") && !seen.has(`${user.id}\n${e.id}`))
.map((entry) => ({ userId: user.id, entry })),
);
for (let i = 0; i < toSend.length; i += WRITE_BATCH) {
const batch = toSend.slice(i, i + WRITE_BATCH);
await prisma.$transaction([
prisma.systemReceipt.createMany({
data: batch.map(({ userId, entry }) => ({ userId, systemId: entry.id })),
skipDuplicates: true,
}),
prisma.notification.createMany({
data: batch.map(({ userId, entry }) => ({
userId,
type: "system",
title: entry.title,
body: entry.body ?? null,
externalUrl: entry.url ?? null,
createdAt: entry.published_at,
})),
}),
]);
}
return toSend.length;
});
}
export async function syncSystemNotifications(url = SYSTEM_NOTIFICATIONS_URL): Promise<void> {
if (!url) return;
const entries = await fetchSystemNotifications(url);
if (entries) await deliverSystemNotifications(entries);
}
/** Once at startup, then every few hours. */
export function startSystemNotificationSync(): void {
const run = () =>
syncSystemNotifications().catch((err) => console.error("Failed to sync system notifications:", err));
void run();
setInterval(run, SYNC_INTERVAL_MS).unref();
}
+13
View File
@@ -25,3 +25,16 @@ export async function mapWithConcurrency<T, R>(
await Promise.all(runners);
return results;
}
/** Runs tasks for the same key one after another; different keys don't wait on each other. */
export function createKeyedLock() {
const tails = new Map<string, Promise<unknown>>();
return function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
const next = (tails.get(key) ?? Promise.resolve()).catch(() => undefined).then(fn);
tails.set(key, next);
void next.finally(() => {
if (tails.get(key) === next) tails.delete(key);
});
return next;
};
}
+14 -17
View File
@@ -221,29 +221,26 @@ describe("the import queue", () => {
expect((await job(parked.id)).status).toBe("PAUSED");
});
it("keeps one notification per queue, updated with the count and unread again", async () => {
it("notifies once when a queue starts, and leaves the notification alone after that", async () => {
await prisma.importJob.deleteMany({ where: { userId: member.id, status: "PAUSED" } });
await prisma.notification.deleteMany({ where: { userId: member.id } });
const first = await queue(member, { url: modelUrl(base + 2) });
const parked = await job(first.body.job_id);
expect(parked.notificationId).toBeTruthy();
await prisma.notification.update({ where: { id: parked.notificationId! }, data: { readAt: new Date() } });
await queue(member, { url: modelUrl(base + 3) });
const notifications = await prisma.notification.findMany({
where: { userId: member.id, title: { contains: "waiting in the import queue" } },
});
const notifications = await prisma.notification.findMany({ where: { userId: member.id, type: "queue" } });
expect(notifications).toHaveLength(1);
expect(notifications[0].title).toBe("2 links waiting in the import queue");
expect(notifications[0].readAt).toBeNull();
expect(notifications[0].title).toBe("Links are waiting in the import queue");
// A member can't open the admin page, so the notification doesn't link there.
expect(notifications[0].internalPath).toBeNull();
// The notification isn't tied to the queue: deleting it leaves the queue as it was.
await prisma.notification.deleteMany({ where: { userId: member.id } });
expect((await job(first.body.job_id)).status).toBe("PAUSED");
});
it("links an admin's notification to the queue page", async () => {
const res = await queue(admin, { url: modelUrl(base + 4) });
const notification = await prisma.notification.findUnique({
where: { id: (await job(res.body.job_id)).notificationId! },
});
await prisma.notification.deleteMany({ where: { userId: admin.id } });
await queue(admin, { url: modelUrl(base + 4) });
const notification = await prisma.notification.findFirst({ where: { userId: admin.id, type: "queue" } });
expect(notification!.internalPath).toBe("/admin-queue");
});
@@ -581,13 +578,13 @@ describe("the import queue", () => {
const removed = await request(app).delete(`/api/admin/import-queue/items/${first.id}`).set(auth(admin));
expect(removed.body).toEqual({ jobDeleted: false });
expect((await job(jobId)).total).toBe(1);
const notification = await prisma.notification.findUnique({ where: { id: (await job(jobId)).notificationId! } });
expect(notification!.title).toBe("1 link waiting in the import queue");
const notificationsBefore = await prisma.notification.count({ where: { userId: member.id } });
const last = await request(app).delete(`/api/admin/import-queue/items/${second.id}`).set(auth(admin));
expect(last.body).toEqual({ jobDeleted: true });
expect(await prisma.importJob.findUnique({ where: { id: jobId } })).toBeNull();
expect(await prisma.notification.findUnique({ where: { id: notification!.id } })).toBeNull();
// Deleting the queue leaves notifications alone.
expect(await prisma.notification.count({ where: { userId: member.id } })).toBe(notificationsBefore);
expect((await request(app).delete(`/api/admin/import-queue/items/${second.id}`).set(auth(admin))).status).toBe(
404,
);
+10 -2
View File
@@ -343,8 +343,16 @@ describe("import job lock", () => {
describe("notifications", () => {
it("lists notifications with an accurate unread count, clearable via mark-all-read", async () => {
await createNotification(userId, { title: "Test notification A", externalUrl: "https://example.com/a" });
await createNotification(userId, { title: "Test notification B", internalPath: "/models/collections/xyz" });
await createNotification(userId, {
type: "import",
title: "Test notification A",
externalUrl: "https://example.com/a",
});
await createNotification(userId, {
type: "sync",
title: "Test notification B",
internalPath: "/models/collections/xyz",
});
const before = await listNotifications(userId);
expect(before.unreadCount).toBeGreaterThanOrEqual(2);
+212
View File
@@ -0,0 +1,212 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import { HttpError } from "../src/utils/fileUtils";
// A file named "corrupt.*" fails to become a model, the way an unreadable file would.
vi.mock("../src/services/printCreation", async (importOriginal) => {
const original = await importOriginal<typeof import("../src/services/printCreation")>();
return {
...original,
createPrint: (async (...args: Parameters<typeof original.createPrint>) => {
if (args[2] === "corrupt") throw new HttpError(422, "Unreadable model file.");
return original.createPrint(...args);
}) as typeof original.createPrint,
};
});
const app = createApp();
const STL =
"solid t\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid t\n";
let token: string;
let userId: string;
const auth = () => ({ Authorization: `Bearer ${token}` });
const notifications = () => prisma.notification.findMany({ where: { userId }, orderBy: { createdAt: "asc" } });
beforeAll(async () => {
const email = `model-notifications-${Date.now()}@example.com`;
const res = await request(app)
.post("/api/register")
.send({ displayName: "Notified", email, password: "password123" });
token = res.body.token;
userId = res.body.user.id;
});
beforeEach(async () => {
await prisma.notification.deleteMany({ where: { userId } });
});
afterAll(async () => {
await prisma.user.deleteMany({ where: { id: userId } });
});
describe("single imports", () => {
const designId = "777555";
const pageUrl = `https://makerworld.com/en/models/${designId}-notify-slug`;
const originalFetch = global.fetch;
function mockMakerworld({ fileFails = false } = {}) {
global.fetch = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async (input) => {
const url = String(input);
if (url.startsWith("https://makerworld.com/en/models/")) {
return new Response("<html><head><title>Notify Test</title></head><body></body></html>", {
headers: { "content-type": "text/html; charset=utf-8" },
});
}
if (url.startsWith("https://makerworld.com/files/")) {
if (fileFails) return new Response("gone", { status: 404 });
return new Response(STL, { headers: { "content-type": "application/octet-stream" } });
}
throw new Error(`Unexpected fetch to ${url}`);
}) as unknown as typeof fetch;
}
const post = (extra: Record<string, unknown> = {}) =>
request(app)
.post("/api/import")
.set(auth())
.send({
url: pageUrl,
resolved_download_url: "https://makerworld.com/files/notify.stl",
resolved_instance_id: "1",
...extra,
});
afterEach(async () => {
global.fetch = originalFetch;
await prisma.print.deleteMany({ where: { userId, sourceExternalId: designId } });
});
it("notifies when an import finishes, and when it was already in the library", async () => {
mockMakerworld();
const first = await post();
expect(first.status).toBe(200);
await post();
const [created, again] = await notifications();
expect(created).toMatchObject({
type: "import",
title: `Imported "${first.body.title || first.body.name}"`,
externalUrl: pageUrl,
internalPath: `/models/${first.body.id}`,
});
expect(again.title).toMatch(/is already in your library$/);
});
it("folds imports one after another into one notification while it's unread", async () => {
mockMakerworld();
const first = await post();
const second = await post({ url: `${pageUrl}#profileId-2`, resolved_instance_id: "2" });
expect(second.body.id).toBe(first.body.id);
// A different design: a second model.
const other = await post({
url: "https://makerworld.com/en/models/777556-other",
resolved_download_url: "https://makerworld.com/files/other.stl",
});
const [merged, ...rest] = await notifications();
expect(rest).toHaveLength(0);
expect(merged).toMatchObject({ type: "import", title: "Imported 2 models", internalPath: "/models" });
expect(merged.body).toContain(`"${other.body.title || other.body.name}"`);
// Once read, the next import starts a notification of its own.
await request(app).post("/api/notifications/read-all").set(auth());
await post({ url: `${pageUrl}#profileId-3`, resolved_instance_id: "3" });
expect(await notifications()).toHaveLength(2);
await prisma.print.deleteMany({ where: { userId, sourceExternalId: "777556" } });
});
it("notifies a failed import with its reason, and stays quiet with notify: false", async () => {
mockMakerworld({ fileFails: true });
const failed = await post();
expect(failed.status).toBeGreaterThanOrEqual(400);
const [notification] = await notifications();
expect(notification).toMatchObject({ type: "import", title: "Couldn't import a model", body: failed.body.detail });
await prisma.notification.deleteMany({ where: { userId } });
mockMakerworld();
expect((await post({ notify: false })).status).toBe(200);
expect(await notifications()).toHaveLength(0);
});
it("sums up a run the extension drove one model at a time", async () => {
mockMakerworld();
const first = await post({ notify: false });
const res = await request(app)
.post("/api/import/summary")
.set(auth())
.send({
source_url: "https://makerworld.com/en/collections/1",
total: 3,
print_ids: [first.body.id, "someone-elses-print"],
stopped: "MakerWorld wants a CAPTCHA.",
});
expect(res.status).toBe(200);
const [summary] = await notifications();
expect(summary).toMatchObject({
type: "import",
title: "Imported 1 of 3 models from MakerWorld",
internalPath: `/models/${first.body.id}`,
});
expect(summary.body).toContain("Stopped early: MakerWorld wants a CAPTCHA.");
});
});
describe("uploads", () => {
const upload = (names: string[], fields: Record<string, string> = {}) => {
let req = request(app).post("/api/upload").set(auth());
for (const [key, value] of Object.entries(fields)) req = req.field(key, value);
for (const name of names) req = req.attach("files", Buffer.from(STL), name);
return req;
};
afterEach(async () => {
const prints = await prisma.print.findMany({ where: { userId }, select: { id: true } });
for (const print of prints) await request(app).delete(`/api/print/${print.id}`).set(auth());
});
it("notifies a single upload, and one request's several models together", async () => {
const one = await upload(["bracket.stl"]);
expect(one.status).toBe(200);
await upload(["left.stl", "right.stl"], { mode: "separate" });
const [single, pair] = await notifications();
expect(single).toMatchObject({
type: "upload",
title: `Uploaded "${one.body.prints[0].name}"`,
internalPath: `/models/${one.body.prints[0].id}`,
});
expect(pair).toMatchObject({ type: "upload", title: "Uploaded 2 models", internalPath: "/models" });
});
it("notifies a failed upload", async () => {
// Two files without a mode is refused.
expect((await upload(["a.stl", "b.stl"])).status).toBe(400);
const [failed] = await notifications();
expect(failed).toMatchObject({ type: "upload", title: "Couldn't upload 2 models" });
expect(failed.body).toContain('"a.stl", "b.stl"');
});
it("reports the models a partly failed upload already created as uploaded", async () => {
const res = await upload(["fine.stl", "corrupt.stl"], { mode: "separate" });
expect(res.status).toBe(422);
const [partial, ...rest] = await notifications();
expect(rest).toHaveLength(0);
expect(partial).toMatchObject({ type: "upload", title: "Uploaded 1 of 2 models" });
expect(partial.body).toBe('Couldn\'t upload "corrupt.stl": Unreadable model file.');
expect(partial.internalPath).toMatch(/^\/models\/.+/);
});
it("sends one summary for a batch uploaded one request at a time", async () => {
const a = await upload(["one.stl"], { notify: "false" });
const b = await upload(["two.stl"], { notify: "false" });
expect(await notifications()).toHaveLength(0);
const res = await request(app)
.post("/api/upload/summary")
.set(auth())
.send({ print_ids: [a.body.prints[0].id, b.body.prints[0].id], failed: ["broken.stl (Upload failed)"] });
expect(res.status).toBe(200);
const [summary] = await notifications();
expect(summary).toMatchObject({ type: "upload", title: "Uploaded 2 of 3 models", internalPath: "/models" });
expect(summary.body).toContain('Couldn\'t upload "broken.stl (Upload failed)".');
});
});
+96
View File
@@ -0,0 +1,96 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import { pruneNotifications } from "../src/services/notificationService";
const app = createApp();
const DAY_MS = 24 * 60 * 60 * 1000;
let token: string;
let userId: string;
let otherToken: string;
let otherId: string;
async function register(name: string) {
const email = `notifications-${name}-${Date.now()}@example.com`;
const res = await request(app).post("/api/register").send({ displayName: name, email, password: "password123" });
return { token: res.body.token as string, id: res.body.user.id as string };
}
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
/** `count` notifications a minute apart, newest last, titled "N 0".."N count-1". */
async function seed(count: number) {
const start = Date.now() - count * 60_000;
await prisma.notification.createMany({
data: Array.from({ length: count }, (_, i) => ({
userId,
type: "import",
title: `N ${i}`,
createdAt: new Date(start + i * 60_000),
})),
});
}
beforeAll(async () => {
({ token, id: userId } = await register("Owner"));
({ token: otherToken, id: otherId } = await register("Other"));
});
afterAll(async () => {
await prisma.user.deleteMany({ where: { id: { in: [userId, otherId] } } });
});
describe("notifications", () => {
it("pages newest first with a cursor, and includes each one's type", async () => {
await prisma.notification.deleteMany({ where: { userId } });
await seed(5);
const first = await request(app).get("/api/notifications?limit=2").set(auth(token));
expect(first.body.items.map((n: { title: string }) => n.title)).toEqual(["N 4", "N 3"]);
expect(first.body.items[0].type).toBe("import");
expect(first.body.unread_count).toBe(5);
// Deleting an item already seen mustn't shift the next page.
await request(app).delete(`/api/notifications/${first.body.items[0].id}`).set(auth(token));
const second = await request(app)
.get(`/api/notifications?limit=2&before=${encodeURIComponent(first.body.next_cursor)}`)
.set(auth(token));
expect(second.body.items.map((n: { title: string }) => n.title)).toEqual(["N 2", "N 1"]);
const last = await request(app)
.get(`/api/notifications?limit=2&before=${encodeURIComponent(second.body.next_cursor)}`)
.set(auth(token));
expect(last.body.items.map((n: { title: string }) => n.title)).toEqual(["N 0"]);
expect(last.body.next_cursor).toBeNull();
});
it("deletes one, but not someone else's, and then all", async () => {
await prisma.notification.deleteMany({ where: { userId } });
await seed(3);
const [newest] = (await request(app).get("/api/notifications").set(auth(token))).body.items;
expect((await request(app).delete(`/api/notifications/${newest.id}`).set(auth(otherToken))).status).toBe(404);
expect((await request(app).delete(`/api/notifications/${newest.id}`).set(auth(token))).status).toBe(200);
expect((await request(app).get("/api/notifications").set(auth(token))).body.items).toHaveLength(2);
expect((await request(app).delete("/api/notifications").set(auth(token))).status).toBe(200);
const after = (await request(app).get("/api/notifications").set(auth(token))).body;
expect(after).toMatchObject({ items: [], unread_count: 0, next_cursor: null });
});
it("prunes read ones after 30 days and unread ones after 90", async () => {
await prisma.notification.deleteMany({ where: { userId } });
const ago = (days: number) => new Date(Date.now() - days * DAY_MS);
await prisma.notification.createMany({
data: [
{ userId, title: "read, 10 days", createdAt: ago(10), readAt: ago(9) },
{ userId, title: "read, 40 days", createdAt: ago(40), readAt: ago(39) },
{ userId, title: "unread, 40 days", createdAt: ago(40) },
{ userId, title: "unread, 100 days", createdAt: ago(100) },
],
});
await pruneNotifications();
const left = await prisma.notification.findMany({ where: { userId }, orderBy: { createdAt: "desc" } });
expect(left.map((n) => n.title)).toEqual(["read, 10 days", "unread, 40 days"]);
});
});
+120
View File
@@ -0,0 +1,120 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import {
deliverSystemNotifications,
parseSystemNotifications,
type SystemNotificationEntry,
} from "../src/services/systemNotificationService";
const app = createApp();
const DAY_MS = 24 * 60 * 60 * 1000;
const run = Date.now();
let admin: { id: string; token: string };
let member: { id: string; token: string };
async function register(name: string, role: "ADMIN" | "MEMBER") {
const email = `system-${name}-${run}@example.com`;
const res = await request(app).post("/api/register").send({ displayName: name, email, password: "password123" });
await prisma.user.update({ where: { id: res.body.user.id }, data: { role } });
const login = await request(app).post("/api/login").send({ email, password: "password123" });
return { id: res.body.user.id as string, token: login.body.token as string };
}
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
const entry = (id: string, daysAgo: number, extra: Partial<SystemNotificationEntry> = {}): SystemNotificationEntry => ({
id: `${id}-${run}`,
title: `Title ${id}`,
published_at: new Date(Date.now() - daysAgo * DAY_MS),
audience: "admins",
...extra,
});
const systemOf = (userId: string) =>
prisma.notification.findMany({ where: { userId, type: "system" }, orderBy: { createdAt: "desc" } });
beforeAll(async () => {
admin = await register("Admin", "ADMIN");
member = await register("Member", "MEMBER");
});
afterAll(async () => {
await prisma.user.deleteMany({ where: { id: { in: [admin.id, member.id] } } });
});
describe("system notifications", () => {
it("skips malformed entries without dropping the rest", () => {
const parsed = parseSystemNotifications({
notifications: [
{ id: "ok", title: "Fine", published_at: "2026-10-01T00:00:00Z", url: "https://example.com" },
{ id: "no-title", published_at: "2026-10-01T00:00:00Z" },
{ id: "bad-url", title: "x", published_at: "2026-10-01T00:00:00Z", url: "not a url" },
{ id: "plain-http", title: "x", published_at: "2026-10-01T00:00:00Z", url: "http://example.com" },
{ id: "bad-audience", title: "x", published_at: "2026-10-01T00:00:00Z", audience: "everyone" },
],
});
expect(parsed.map((e) => e.id)).toEqual(["ok"]);
expect(parsed[0].audience).toBe("admins");
expect(parseSystemNotifications({ nope: true })).toEqual([]);
});
it("sends due entries to admins only, dated when they were posted", async () => {
const posted = entry("bug", 2, { body: "Imports fail on 1.2", url: "https://example.com/issue" });
await deliverSystemNotifications([posted, entry("future", -3), entry("stale", 120)]);
const [notification, ...rest] = await systemOf(admin.id);
expect(rest).toHaveLength(0);
expect(notification).toMatchObject({
title: "Title bug",
body: "Imports fail on 1.2",
externalUrl: "https://example.com/issue",
readAt: null,
});
expect(notification.createdAt.getTime()).toBe(posted.published_at.getTime());
expect(await systemOf(member.id)).toHaveLength(0);
});
it("sends an entry for everyone to members too", async () => {
await deliverSystemNotifications([entry("everyone", 1, { audience: "all" })]);
expect((await systemOf(member.id)).map((n) => n.title)).toEqual(["Title everyone"]);
expect((await systemOf(admin.id)).some((n) => n.title === "Title everyone")).toBe(true);
});
it("never sends the same entry twice, even after it was deleted", async () => {
const once = entry("once", 1);
await deliverSystemNotifications([once]);
await deliverSystemNotifications([once]);
const sent = (await systemOf(admin.id)).filter((n) => n.title === "Title once");
expect(sent).toHaveLength(1);
await request(app).delete(`/api/notifications/${sent[0].id}`).set(auth(admin.token));
await deliverSystemNotifications([once]);
expect((await systemOf(admin.id)).some((n) => n.title === "Title once")).toBe(false);
});
it("filters by tab, keeps what was new when opened in Unread, and clears only the chosen tab", async () => {
await prisma.notification.deleteMany({ where: { userId: admin.id } });
await prisma.notification.create({
data: { userId: admin.id, type: "import", title: "Seen before", readAt: new Date(Date.now() - DAY_MS) },
});
await prisma.notification.create({ data: { userId: admin.id, type: "import", title: "Imported" } });
await deliverSystemNotifications([entry("tabs", 0)]);
const list = async (query: string) =>
(await request(app).get(`/api/notifications?${query}`).set(auth(admin.token))).body.items
.map((n: { title: string }) => n.title)
.toSorted();
expect(await list("filter=system")).toEqual(["Title tabs"]);
// Opening the list marks everything read; Unread still shows what was new at that moment.
const opened = await request(app).post("/api/notifications/read-all").set(auth(admin.token));
const since = encodeURIComponent(opened.body.read_at);
expect(await list("filter=unread")).toEqual([]);
expect(await list(`filter=unread&since=${since}`)).toEqual(["Imported", "Title tabs"]);
await request(app).delete("/api/notifications?filter=system").set(auth(admin.token));
expect(await list("filter=all")).toEqual(["Imported", "Seen before"]);
await request(app).delete(`/api/notifications?filter=unread&since=${since}`).set(auth(admin.token));
expect(await list("filter=all")).toEqual(["Seen before"]);
});
});
+29
View File
@@ -221,6 +221,35 @@ go run ./cmd/thingport-bridge
macOS URL-scheme handling uses Cocoa and needs cgo, so the macOS build only works on a Mac.
Windows and Linux binaries cross-compile with `CGO_ENABLED=0`.
## System notifications
Announcements for every instance, such as a known bug or a new feature, live in
[`system-notifications.json`](../system-notifications.json) at the repo root. Each backend fetches it
from `main` at startup and every 6 hours, and sends each entry once to each user it's meant for, as an
ordinary notification (the System tab). To post one, add an entry and merge it:
```json
{
"notifications": [
{
"id": "2026-10-imports-fail-on-printables",
"title": "Printables imports fail since Oct 10",
"body": "Printables changed its API. A fix is on the way; update when it lands.",
"url": "https://github.com/TautvydasDerzinskas/Thingport/issues/123",
"published_at": "2026-10-11T12:00:00Z",
"audience": "admins"
}
]
}
```
- `id` must be unique and never reused: it's what stops an entry being sent twice.
- `body` and `url` are optional; `url` must be `https://`, and the notification shows its domain.
- `published_at` is the date shown; a future date waits until then.
- `audience` is `admins` (the default) or `all` for every user.
- Entries older than 90 days aren't sent, so old ones can stay in the file or be removed.
- `SYSTEM_NOTIFICATIONS_URL` points a backend at another file; an empty value turns fetching off.
## Related documents
- [CONTRIBUTING.md](../CONTRIBUTING.md) -- branch, commit and pull request workflow
+10 -8
View File
@@ -28,13 +28,11 @@ async function pollImportTask(taskId: string): Promise<Print> {
/** One message covering import and collection filing, so both complete even if the tab navigates
* away: the background outlives the content script. */
export async function importSingle({
url,
entries,
collectionId,
resolved,
title,
}: ImportSinglePayload): Promise<Print | null> {
/** `notify: false` for each model of a run that sends its own summary at the end. */
export async function importSingle(
{ url, entries, collectionId, resolved, title }: ImportSinglePayload,
{ notify = true }: { notify?: boolean } = {},
): Promise<Print | null> {
// A page-resolved download lets the backend skip the resolution calls that trip MakerWorld's
// CAPTCHA. The profile id lets another profile of an existing model be added as a file.
const extra = resolved?.downloadUrl
@@ -55,7 +53,11 @@ export async function importSingle({
// Asynchronous so the request never outlives the worker: Chromium kills an extension service
// worker whose fetch() takes over 30s to answer. An older instance ignores `async` and
// answers with the print itself.
const started = await apiCall<Print | { task_id: string }>("POST", "/import?async=1", { url, ...extra });
const started = await apiCall<Print | { task_id: string }>("POST", "/import?async=1", {
url,
...extra,
...(notify ? {} : { notify: false }),
});
print = "task_id" in started ? await pollImportTask(started.task_id) : started;
}
+24 -2
View File
@@ -36,6 +36,18 @@ function isCaptchaMessage(message: string): boolean {
return /captcha/i.test(message);
}
/** One notification for the whole run, instead of one per model. Best-effort: an older backend has
* no summary endpoint. */
async function sendRunSummary(job: MakerworldJob, stopped: string | null): Promise<void> {
await apiCall("POST", "/import/summary", {
source_url: job.originalUrl,
total: job.total,
print_ids: job.printIds ?? [],
collection_id: job.collectionId,
stopped,
}).catch(() => undefined);
}
/** `urls` are the not-yet-imported designs. tabs.onUpdated drives every step after the first. */
export async function startJob(
tabId: number,
@@ -59,6 +71,7 @@ export async function startJob(
stepDelayMs,
startAt: 0,
paused: false,
printIds: [],
});
await chrome.tabs.update(tabId, { url: urls[0] });
return null;
@@ -69,6 +82,7 @@ export async function abortJob(tabId: number): Promise<null> {
const job = await getJob();
if (!job || job.tabId !== tabId) return null;
await setJob(null);
void sendRunSummary(job, "you stopped the import.");
await chrome.tabs.update(tabId, { url: job.originalUrl });
return null;
}
@@ -105,6 +119,7 @@ async function moveForward(tabId: number, job: MakerworldJob): Promise<void> {
job.index += 1;
if (job.index >= job.urls.length) {
await setJob(null);
void sendRunSummary(job, null);
await chrome.tabs.update(tabId, { url: job.originalUrl });
return;
}
@@ -151,7 +166,8 @@ export async function advanceJob(tabId: number): Promise<void> {
try {
if (resolveError) throw new Error(resolveError);
await importSingle({ url: currentUrl, collectionId: job.collectionId, resolved });
const print = await importSingle({ url: currentUrl, collectionId: job.collectionId, resolved }, { notify: false });
if (print?.id) job.printIds = [...(job.printIds ?? []), print.id];
} catch (err) {
// A manual "Import next" may have moved past this step while the request was in flight.
if (!(await isStillAtStep(tabId, stepIndex))) return;
@@ -163,6 +179,7 @@ export async function advanceJob(tabId: number): Promise<void> {
return;
}
await setJob(null);
void sendRunSummary(job, message);
const error: MakerworldJobError = { message, imported: job.imported, total: job.total };
await chrome.storage.local.set({ [JOB_ERROR_STORAGE_KEY]: error });
await chrome.tabs.update(tabId, { url: job.originalUrl });
@@ -184,6 +201,7 @@ export async function forceAdvanceJob(tabId: number): Promise<null> {
const status = await apiCall<ImportStatus>("GET", `/import/status?url=${encodeURIComponent(currentUrl)}`);
if (status.already_imported) {
job.imported += 1;
if (status.print_id) job.printIds = [...(job.printIds ?? []), status.print_id];
if (job.collectionId && status.print_id) {
await apiCall("POST", `/collection/${job.collectionId}/items/${status.print_id}`).catch(() => undefined);
}
@@ -195,7 +213,11 @@ export async function forceAdvanceJob(tabId: number): Promise<null> {
return null;
}
/** Closing the tab ends the run; its summary still goes out. A model still importing when the tab
* closed lands, but isn't counted in it. */
export async function dropJobIfForTab(tabId: number): Promise<void> {
const job = await getJob();
if (job && job.tabId === tabId) await setJob(null);
if (!job || job.tabId !== tabId) return;
await setJob(null);
await sendRunSummary(job, "the MakerWorld tab was closed.");
}
+2
View File
@@ -58,6 +58,8 @@ export type MakerworldJob = {
startAt: number;
/** MakerWorld wants a CAPTCHA: the run waits on this model until the user resumes it. */
paused: boolean;
/** The models imported so far, for the run's one notification. Missing on a run saved by an older version. */
printIds?: string[];
};
export type MakerworldJobError = { message: string; imported: number; total: number };
+2
View File
@@ -26,6 +26,7 @@ import AdminPage from "./pages/AdminPage";
import AdminSettingsPage from "./pages/AdminSettingsPage";
import UsersPage from "./pages/UsersPage";
import LogsPage from "./pages/LogsPage";
import NotificationsPage from "./pages/NotificationsPage";
import TriggersPage from "./pages/TriggersPage";
import ConnectionsPage from "./pages/ConnectionsPage";
import CaptchaPage from "./pages/CaptchaPage";
@@ -210,6 +211,7 @@ function AppShell({
/>
<Route path="/profile/password" element={<ChangePasswordPage onUnauthorized={onUnauthorized} />} />
<Route path="/downloads" element={<DownloadPage />} />
<Route path="/notifications" element={<NotificationsPage onUnauthorized={onUnauthorized} />} />
<Route
path="/admin"
element={isAdmin ? <AdminPage onUnauthorized={onUnauthorized} /> : <Navigate to="/" replace />}
+44 -3
View File
@@ -1,33 +1,74 @@
import { authHeaders } from "../utils/auth";
import { apiBase, assertOk } from "./client";
/** Kept in sync by hand with the backend's NOTIFICATION_TYPES. Unknown types show as "general". */
export type NotificationType = "import" | "upload" | "sync" | "queue" | "consume" | "ai" | "system" | "general";
/** The tabs. "system" holds announcements from the Thingport repo. */
export type NotificationFilter = "all" | "unread" | "system";
export type Notification = {
id: string;
type: NotificationType;
title: string;
body: string | null;
external_url: string | null;
internal_path: string | null;
read: boolean;
read_at: string | null;
created_at: string;
};
export type NotificationsListResult = {
items: Notification[];
unread_count: number;
/** Pass as `before` for the next page; null on the last one. */
next_cursor: string | null;
};
export const notificationsApi = {
list: async (): Promise<NotificationsListResult> => {
const res = await fetch(`${apiBase()}/notifications`, { headers: authHeaders() });
list: async (
options: { limit?: number; before?: string | null; filter?: NotificationFilter; since?: string | null } = {},
): Promise<NotificationsListResult> => {
const params = new URLSearchParams();
if (options.filter && options.filter !== "all") params.set("filter", options.filter);
if (options.limit) params.set("limit", String(options.limit));
if (options.before) params.set("before", options.before);
if (options.since) params.set("since", options.since);
const query = params.toString();
const res = await fetch(`${apiBase()}/notifications${query ? `?${query}` : ""}`, { headers: authHeaders() });
assertOk(res, "Failed to load notifications");
return res.json();
},
markAllRead: async (): Promise<void> => {
/** Returns the time stamped on them; null from a backend too old to say. */
markAllRead: async (): Promise<string | null> => {
const res = await fetch(`${apiBase()}/notifications/read-all`, {
method: "POST",
headers: authHeaders(),
});
assertOk(res, "Failed to mark notifications as read");
const body = (await res.json()) as { read_at?: string };
return body.read_at ?? null;
},
remove: async (id: string): Promise<void> => {
const res = await fetch(`${apiBase()}/notifications/${encodeURIComponent(id)}`, {
method: "DELETE",
headers: authHeaders(),
});
// Already gone, e.g. deleted in another tab, is what the caller wanted anyway.
if (res.status === 404) return;
assertOk(res, "Failed to delete the notification");
},
/** Deletes every notification in the tab. */
clearAll: async (filter: NotificationFilter = "all", since: string | null = null): Promise<void> => {
const params = new URLSearchParams();
if (filter !== "all") params.set("filter", filter);
if (since) params.set("since", since);
const query = params.toString() ? `?${params}` : "";
const res = await fetch(`${apiBase()}/notifications${query}`, { method: "DELETE", headers: authHeaders() });
assertOk(res, "Failed to delete notifications");
},
};
+13
View File
@@ -214,6 +214,8 @@ export const printsApi = {
tags?: string[];
category_id?: string;
mode?: "separate" | "multiplate";
/** False for each request of a batch; the batch sends one summary (uploadSummary) instead. */
notify?: boolean;
} = {},
): Promise<UploadPrintsResult> => {
const fd = new FormData();
@@ -225,6 +227,7 @@ export const printsApi = {
if (opts.tags && opts.tags.length) fd.set("tags", opts.tags.join(","));
if (opts.category_id) fd.set("category_id", opts.category_id);
if (opts.mode && files.length > 1) fd.set("mode", opts.mode);
if (opts.notify === false) fd.set("notify", "false");
const res = await fetch(`${apiBase()}/upload`, {
method: "POST",
body: fd,
@@ -313,6 +316,16 @@ export const printsApi = {
return res.json();
},
/** One notification for a batch uploaded one request at a time. Best-effort. */
uploadSummary: async (printIds: string[], failed: string[]): Promise<void> => {
const res = await fetch(`${apiBase()}/upload/summary`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ print_ids: printIds, failed }),
});
assertOk(res, "Failed to record the upload");
},
uploadGeneratedThumbnail: async (plateId: string, image: Blob): Promise<Print> => {
const body = new FormData();
body.set("file", image, "thumbnail.png");
@@ -90,6 +90,9 @@ function useRouteChrome() {
} else if (path === "/profile") {
title = t("profile.title");
onBack = goBack;
} else if (path === "/notifications") {
title = t("notifications.label");
onBack = goBack;
} else if (path === "/downloads") {
title = t("sidebar.downloads");
onBack = () => navigate("/");
@@ -3,107 +3,128 @@ import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import IconButton from "@mui/material/IconButton";
import Badge from "@mui/material/Badge";
import Tooltip from "@mui/material/Tooltip";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import Divider from "@mui/material/Divider";
import Link from "@mui/material/Link";
import Popover from "@mui/material/Popover";
import Stack from "@mui/material/Stack";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import NotificationsIcon from "@mui/icons-material/Notifications";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import { useNotifications } from "./NotificationsContext";
import type { Notification } from "../../api/notifications";
import { useConfirm } from "../ConfirmProvider";
import NotificationList from "../notifications/NotificationList";
import NotificationTabs, { clearConfirmKey, emptyKey } from "../notifications/NotificationTabs";
import { useNotificationFeed, useOpenedSince } from "../notifications/useNotificationFeed";
import type { Notification, NotificationFilter } from "../../api/notifications";
function relativeTime(iso: string, t: (key: string, opts?: Record<string, unknown>) => string): string {
const diffMs = Date.now() - new Date(iso).getTime();
const minutes = Math.round(diffMs / 60_000);
if (minutes < 1) return t("notifications.justNow");
if (minutes < 60) return t("notifications.minutesAgo", { count: minutes });
const hours = Math.round(minutes / 60);
if (hours < 24) return t("notifications.hoursAgo", { count: hours });
const days = Math.round(hours / 24);
return t("notifications.daysAgo", { count: days });
}
const PREVIEW_LIMIT = 10;
export default function NotificationBell() {
/** Mounted only while the dropdown is open, so each opening loads the tab fresh. */
function NotificationDropdown({ onClose }: { onClose: () => void }) {
const { t } = useTranslation("app");
const navigate = useNavigate();
const { items, unreadCount, markAllRead } = useNotifications();
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const confirmDialog = useConfirm();
const [filter, setFilter] = useState<NotificationFilter>("all");
const opened = useOpenedSince();
const feed = useNotificationFeed(filter, PREVIEW_LIMIT, opened);
const handleOpen = (e: React.MouseEvent<HTMLElement>) => {
setAnchorEl(e.currentTarget);
if (unreadCount > 0) void markAllRead();
const handleOpenItem = (notification: Notification) => {
if (!notification.internal_path) return;
onClose();
navigate(notification.internal_path);
};
const handleClose = () => setAnchorEl(null);
const handleClear = async () => {
const confirmed = await confirmDialog({
message: t(clearConfirmKey(filter)),
confirmLabel: t("notifications.clearAll"),
destructive: true,
});
if (confirmed) void feed.clear();
};
const handleItemClick = (notification: Notification) => {
handleClose();
if (notification.internal_path) navigate(notification.internal_path);
const handleViewAll = () => {
onClose();
navigate(filter === "all" ? "/notifications" : `/notifications?tab=${filter}`);
};
return (
<>
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ pl: 2, pr: 1, py: 1 }}>
<Typography variant="subtitle1" fontWeight={700} sx={{ flex: 1 }}>
{t("notifications.label")}
</Typography>
{feed.items.length > 0 && (
<Button size="small" color="inherit" onClick={() => void handleClear()} sx={{ color: "text.secondary" }}>
{t("notifications.clearAll")}
</Button>
)}
</Stack>
<NotificationTabs value={filter} onChange={setFilter} />
<Divider />
<Box sx={{ overflowY: "auto", flex: 1, px: 1, py: 1, minHeight: 120 }}>
{feed.items.length > 0 ? (
<NotificationList
items={feed.items}
since={opened.since}
onOpen={handleOpenItem}
onDelete={(n) => void feed.remove(n)}
/>
) : feed.loading ? (
<Stack alignItems="center" sx={{ py: 3 }}>
<CircularProgress size={20} />
</Stack>
) : (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: "center", py: 3 }}>
{t(feed.failed ? "notifications.loadFailed" : emptyKey(filter))}
</Typography>
)}
</Box>
<Divider />
<Button onClick={handleViewAll} sx={{ borderRadius: 0, py: 1.25 }}>
{t("notifications.viewAll")}
</Button>
</>
);
}
export default function NotificationBell() {
const { t } = useTranslation("app");
const { unreadCount } = useNotifications();
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const handleClose = () => setAnchorEl(null);
return (
<>
<Tooltip title={t("notifications.label")}>
<IconButton size="small" onClick={handleOpen}>
<IconButton size="small" onClick={(e) => setAnchorEl(e.currentTarget)} aria-label={t("notifications.label")}>
<Badge badgeContent={unreadCount} color="error" max={99}>
<NotificationsIcon fontSize="small" />
</Badge>
</IconButton>
</Tooltip>
<Menu
<Popover
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleClose}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
slotProps={{ paper: { sx: { width: 360, maxHeight: 420 } } }}
slotProps={{
paper: {
sx: {
width: 380,
maxWidth: "calc(100vw - 32px)",
display: "flex",
flexDirection: "column",
maxHeight: 560,
},
},
}}
>
{items.length === 0 && (
<Stack alignItems="center" spacing={0.5} sx={{ py: 3, color: "text.secondary" }}>
<Typography variant="body2">{t("notifications.empty")}</Typography>
</Stack>
)}
{items.map((notification, idx) => (
<div key={notification.id}>
{idx > 0 && <Divider />}
<MenuItem
onClick={() => handleItemClick(notification)}
sx={{ whiteSpace: "normal", alignItems: "flex-start", py: 1 }}
>
<Stack spacing={0.25} sx={{ minWidth: 0, width: "100%" }}>
<Typography variant="body2" fontWeight={notification.read ? 400 : 700}>
{notification.title}
</Typography>
{notification.body && (
<Typography variant="caption" color="text.secondary">
{notification.body}
</Typography>
)}
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 0.25 }}>
<Typography variant="caption" color="text.disabled">
{relativeTime(notification.created_at, t)}
</Typography>
{notification.external_url && (
<Link
href={notification.external_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
sx={{ display: "inline-flex", alignItems: "center", gap: 0.25, fontSize: 12 }}
>
{t("notifications.viewSource")}
<OpenInNewIcon sx={{ fontSize: 12 }} />
</Link>
)}
</Stack>
</Stack>
</MenuItem>
</div>
))}
</Menu>
{anchorEl && <NotificationDropdown onClose={handleClose} />}
</Popover>
</>
);
}
@@ -1,13 +1,13 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import type React from "react";
import { notificationsApi, type Notification } from "../../api/notifications";
import { notificationsApi } from "../../api/notifications";
import { UnauthorizedError } from "../../api/client";
type NotificationsContextValue = {
items: Notification[];
unreadCount: number;
refresh: () => Promise<void>;
markAllRead: () => Promise<void>;
/** Resolves to the time stamped on them (null if it failed): what counts as new while the list is open. */
markAllRead: () => Promise<string | null>;
};
const NotificationsContext = createContext<NotificationsContextValue | null>(null);
@@ -15,6 +15,7 @@ const NotificationsContext = createContext<NotificationsContextValue | null>(nul
// Only catches notifications from other tabs/devices; the tracked import job refreshes directly.
const POLL_INTERVAL_MS = 45_000;
/** The unread count for the bell's badge. The lists themselves load per tab (useNotificationFeed). */
export function NotificationsProvider({
onUnauthorized,
children,
@@ -22,27 +23,35 @@ export function NotificationsProvider({
onUnauthorized?: () => void;
children: React.ReactNode;
}) {
const [items, setItems] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const refresh = useCallback(async () => {
try {
const result = await notificationsApi.list();
setItems(result.items);
const result = await notificationsApi.list({ limit: 1 });
setUnreadCount(result.unread_count);
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
}
}, [onUnauthorized]);
const markAllRead = useCallback(async () => {
try {
await notificationsApi.markAllRead();
setUnreadCount(0);
setItems((prev) => prev.map((n) => ({ ...n, read: true })));
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
}
// Overlapping calls share one request: a second one would find nothing left to mark and answer a
// later time, hiding what the first one marked (React runs effects twice in development).
const pendingMarkAll = useRef<Promise<string | null> | null>(null);
const markAllRead = useCallback(() => {
pendingMarkAll.current ??= notificationsApi
.markAllRead()
.then((readAt) => {
setUnreadCount(0);
return readAt;
})
.catch((err: unknown) => {
if (err instanceof UnauthorizedError) onUnauthorized?.();
return null;
})
.finally(() => {
pendingMarkAll.current = null;
});
return pendingMarkAll.current;
}, [onUnauthorized]);
useEffect(() => {
@@ -54,10 +63,7 @@ export function NotificationsProvider({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const value = useMemo(
() => ({ items, unreadCount, refresh, markAllRead }),
[items, unreadCount, refresh, markAllRead],
);
const value = useMemo(() => ({ unreadCount, refresh, markAllRead }), [unreadCount, refresh, markAllRead]);
return <NotificationsContext.Provider value={value}>{children}</NotificationsContext.Provider>;
}
@@ -0,0 +1,247 @@
import React from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { alpha, type Theme } from "@mui/material/styles";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import Link from "@mui/material/Link";
import Stack from "@mui/material/Stack";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import CloseIcon from "@mui/icons-material/Close";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import CloudDownloadOutlinedIcon from "@mui/icons-material/CloudDownloadOutlined";
import CloudUploadOutlinedIcon from "@mui/icons-material/CloudUploadOutlined";
import SyncIcon from "@mui/icons-material/Sync";
import HourglassEmptyIcon from "@mui/icons-material/HourglassEmpty";
import MoveToInboxOutlinedIcon from "@mui/icons-material/MoveToInboxOutlined";
import AutoAwesomeOutlinedIcon from "@mui/icons-material/AutoAwesomeOutlined";
import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone";
import CampaignOutlinedIcon from "@mui/icons-material/CampaignOutlined";
import type { Notification, NotificationType } from "../../api/notifications";
import { isNew } from "./useNotificationFeed";
type Palette = "primary" | "info" | "warning" | "secondary" | "success" | "error";
const TYPE_STYLE: Record<NotificationType, { Icon: typeof SyncIcon; color: Palette | null }> = {
import: { Icon: CloudDownloadOutlinedIcon, color: "primary" },
upload: { Icon: CloudUploadOutlinedIcon, color: "primary" },
sync: { Icon: SyncIcon, color: "info" },
queue: { Icon: HourglassEmptyIcon, color: "warning" },
consume: { Icon: MoveToInboxOutlinedIcon, color: "success" },
ai: { Icon: AutoAwesomeOutlinedIcon, color: "secondary" },
system: { Icon: CampaignOutlinedIcon, color: "error" },
general: { Icon: NotificationsNoneIcon, color: null },
};
function typeColor(theme: Theme, color: Palette | null): string {
return color ? theme.palette[color].main : theme.palette.text.secondary;
}
/** Shows where an outside link goes, e.g. "makerworld.com". */
function linkHost(url: string): string | null {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return null;
}
}
function startOfDay(date: Date): number {
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
}
type Group = { key: string; label: string; items: Notification[] };
/** Today, Yesterday, then one group per date, in the order the items come (newest first). */
function groupByDay(items: Notification[], t: TFunction, locale: string): Group[] {
const today = startOfDay(new Date());
const yesterday = today - 24 * 60 * 60 * 1000;
const dateFormat = new Intl.DateTimeFormat(locale, { day: "numeric", month: "short", year: "numeric" });
const groups: Group[] = [];
for (const item of items) {
const created = new Date(item.created_at);
const day = startOfDay(created);
const key = String(day);
let group = groups[groups.length - 1];
if (!group || group.key !== key) {
const label =
day === today
? t("notifications.today")
: day === yesterday
? t("notifications.yesterday")
: dateFormat.format(created);
group = { key, label, items: [] };
groups.push(group);
}
group.items.push(item);
}
return groups;
}
/** Relative today ("5 minutes ago"); the time of day for older ones, whose date is the group heading. */
function timeLabel(iso: string, t: TFunction, locale: string): string {
const created = new Date(iso);
if (startOfDay(created) !== startOfDay(new Date())) {
return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit" }).format(created);
}
const minutes = Math.round((Date.now() - created.getTime()) / 60_000);
if (minutes < 1) return t("notifications.justNow");
if (minutes < 60) return t("notifications.minutesAgo", { count: minutes });
return t("notifications.hoursAgo", { count: Math.round(minutes / 60) });
}
function NotificationRow({
notification,
highlighted,
locale,
onOpen,
onDelete,
}: {
notification: Notification;
/** New when the list was opened. */
highlighted: boolean;
locale: string;
onOpen: (notification: Notification) => void;
onDelete: (notification: Notification) => void;
}) {
const { t } = useTranslation("app");
const { Icon, color } = TYPE_STYLE[notification.type] ?? TYPE_STYLE.general;
const clickable = Boolean(notification.internal_path);
return (
<Box
sx={{
position: "relative",
borderRadius: 1.5,
"&:hover": { bgcolor: "action.hover" },
// The delete button only shows on hover where there is one; touch screens always show it.
"@media (hover: hover)": {
"& .notification-delete": { opacity: 0 },
"&:hover .notification-delete, &:focus-within .notification-delete": { opacity: 1 },
},
}}
>
<Box
role={clickable ? "link" : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={clickable ? () => onOpen(notification) : undefined}
onKeyDown={
clickable
? (event: React.KeyboardEvent) => {
if (event.key === "Enter") onOpen(notification);
}
: undefined
}
sx={{
display: "flex",
alignItems: "flex-start",
gap: 1.5,
p: 1.25,
pr: 5,
borderRadius: 1.5,
cursor: clickable ? "pointer" : "default",
outline: "none",
"&:focus-visible": { boxShadow: (theme) => `0 0 0 2px ${theme.palette.primary.main}` },
}}
>
<Box
sx={(theme) => ({
width: 36,
height: 36,
flexShrink: 0,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: typeColor(theme, color),
bgcolor: alpha(typeColor(theme, color), 0.14),
})}
>
<Icon sx={{ fontSize: 19 }} />
</Box>
<Stack spacing={0.25} sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="body2" fontWeight={highlighted ? 700 : 500} sx={{ wordBreak: "break-word" }}>
{notification.title}
</Typography>
{notification.body && (
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: "break-word" }}>
{notification.body}
</Typography>
)}
<Stack direction="row" alignItems="center" spacing={1.5}>
<Typography variant="caption" color="text.disabled">
{timeLabel(notification.created_at, t, locale)}
</Typography>
{notification.external_url && (
<Link
href={notification.external_url}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
sx={{ display: "inline-flex", alignItems: "center", gap: 0.25, fontSize: 12 }}
>
{linkHost(notification.external_url) ?? t("notifications.viewSource")}
<OpenInNewIcon sx={{ fontSize: 12 }} />
</Link>
)}
</Stack>
</Stack>
</Box>
<Tooltip title={t("notifications.delete")}>
<IconButton
className="notification-delete"
size="small"
aria-label={t("notifications.delete")}
onClick={() => onDelete(notification)}
sx={{ position: "absolute", top: 6, right: 6, transition: "opacity 120ms" }}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
</Box>
);
}
type Props = {
items: Notification[];
/** When the list was opened; items read since then still show as new. */
since: string | null;
onOpen: (notification: Notification) => void;
onDelete: (notification: Notification) => void;
};
/** Notifications grouped by day under small headings, the way the bell and the full page both show them. */
export default function NotificationList({ items, since, onOpen, onDelete }: Props) {
const { t, i18n } = useTranslation("app");
const locale = i18n.resolvedLanguage ?? i18n.language;
const groups = React.useMemo(() => groupByDay(items, t, locale), [items, t, locale]);
return (
<Stack spacing={1.5}>
{groups.map((group) => (
<Box key={group.key}>
<Typography
variant="overline"
color="text.secondary"
sx={{ display: "block", px: 1.25, lineHeight: 2, letterSpacing: "0.08em" }}
>
{group.label}
</Typography>
<Stack spacing={0.25}>
{group.items.map((notification) => (
<NotificationRow
key={notification.id}
notification={notification}
highlighted={isNew(notification, since)}
locale={locale}
onOpen={onOpen}
onDelete={onDelete}
/>
))}
</Stack>
</Box>
))}
</Stack>
);
}
@@ -0,0 +1,39 @@
import { useTranslation } from "react-i18next";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
import type { NotificationFilter } from "../../api/notifications";
type Props = {
value: NotificationFilter;
onChange: (value: NotificationFilter) => void;
};
export default function NotificationTabs({ value, onChange }: Props) {
const { t } = useTranslation("app");
return (
<Tabs
value={value}
onChange={(_event, next: NotificationFilter) => onChange(next)}
variant="fullWidth"
sx={{ minHeight: 38, "& .MuiTab-root": { minHeight: 38, py: 0.5, textTransform: "none", fontWeight: 600 } }}
>
<Tab value="all" label={t("notifications.tabAll")} />
<Tab value="unread" label={t("notifications.tabUnread")} />
<Tab value="system" label={t("notifications.tabSystem")} />
</Tabs>
);
}
/** The empty-state text for a tab. */
export function emptyKey(filter: NotificationFilter): string {
if (filter === "unread") return "notifications.emptyUnread";
if (filter === "system") return "notifications.emptySystem";
return "notifications.empty";
}
/** The "Clear all" confirmation for a tab. */
export function clearConfirmKey(filter: NotificationFilter): string {
if (filter === "unread") return "notifications.clearUnreadConfirm";
if (filter === "system") return "notifications.clearSystemConfirm";
return "notifications.clearAllConfirm";
}
@@ -0,0 +1,121 @@
import React from "react";
import { UnauthorizedError } from "../../api/client";
import { notificationsApi, type Notification, type NotificationFilter } from "../../api/notifications";
import { useNotifications } from "../Layout/NotificationsContext";
/** Opening a list marks everything read, as it always has. The returned time is kept so the
* "unread" tab and the highlight still show what was new at that moment. */
export function useOpenedSince(): { since: string | null; ready: boolean } {
const { markAllRead } = useNotifications();
const [state, setState] = React.useState<{ since: string | null; ready: boolean }>({ since: null, ready: false });
React.useEffect(() => {
let active = true;
void markAllRead().then((since) => {
if (active) setState({ since, ready: true });
});
return () => {
active = false;
};
// Once per opening.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return state;
}
/** Unread, or read by this opening. */
export function isNew(notification: Notification, since: string | null): boolean {
if (!notification.read) return true;
return Boolean(since && notification.read_at && notification.read_at >= since);
}
/** One tab's notifications, a page at a time. Waits for `ready` so "unread" knows its `since`. */
export function useNotificationFeed(
filter: NotificationFilter,
pageSize: number,
opened: { since: string | null; ready: boolean },
onUnauthorized?: () => void,
) {
const { refresh: refreshCount } = useNotifications();
const { since, ready } = opened;
const [items, setItems] = React.useState<Notification[]>([]);
const [cursor, setCursor] = React.useState<string | null>(null);
const [done, setDone] = React.useState(false);
const [loading, setLoading] = React.useState(true);
const [failed, setFailed] = React.useState(false);
// Drops pages that answer after the tab changed.
const generation = React.useRef(0);
const loadingRef = React.useRef(false);
const handleError = React.useCallback(
(err: unknown) => {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setFailed(true);
},
[onUnauthorized],
);
const fetchPage = React.useCallback(
async (before: string | null, reset: boolean) => {
if (loadingRef.current && !reset) return;
const current = reset ? ++generation.current : generation.current;
loadingRef.current = true;
setLoading(true);
try {
const page = await notificationsApi.list({ limit: pageSize, before, filter, since });
if (current !== generation.current) return;
setItems((prev) => {
if (reset) return page.items;
const seen = new Set(prev.map((n) => n.id));
return [...prev, ...page.items.filter((n) => !seen.has(n.id))];
});
setCursor(page.next_cursor);
setDone(page.next_cursor === null);
setFailed(false);
} catch (err) {
if (current === generation.current) handleError(err);
} finally {
if (current === generation.current) {
loadingRef.current = false;
setLoading(false);
}
}
},
[filter, pageSize, since, handleError],
);
const loadMore = React.useCallback(() => {
if (ready && !done) void fetchPage(cursor, false);
}, [ready, cursor, done, fetchPage]);
React.useEffect(() => {
setItems([]);
setDone(false);
if (ready) void fetchPage(null, true);
}, [ready, fetchPage]);
const remove = React.useCallback(
async (notification: Notification) => {
setItems((prev) => prev.filter((n) => n.id !== notification.id));
try {
await notificationsApi.remove(notification.id);
} catch (err) {
handleError(err);
}
void refreshCount();
},
[handleError, refreshCount],
);
const clear = React.useCallback(async () => {
try {
await notificationsApi.clearAll(filter, since);
setItems([]);
setDone(true);
} catch (err) {
handleError(err);
}
void refreshCount();
}, [filter, since, handleError, refreshCount]);
return { items, loading: loading || !ready, done, failed, loadMore, remove, clear };
}
@@ -5,6 +5,7 @@ import { useNavigate } from "react-router-dom";
import { UnauthorizedError } from "../../api/client";
import { importsApi, type ImportOutcome, type MakerworldProfileScope } from "../../api/imports";
import { printsApi, type Print } from "../../api/prints";
import { useNotifications } from "../Layout/NotificationsContext";
import {
entriesFromFileList,
hasModelFolders,
@@ -45,6 +46,7 @@ type Props = {
export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUnauthorized }: Props) {
const { t } = useTranslation("app");
const showToast = useToast();
const { refresh: refreshNotifications } = useNotifications();
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement | null>(null);
const folderInputRef = useRef<HTMLInputElement | null>(null);
@@ -102,10 +104,19 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
let uploaded = 0;
const failed: string[] = [];
const prints: Print[] = [];
const applyResult = (result: { uploaded: number; failed: string[]; prints?: Print[] }) => {
// Batches upload without a notification per request; they get one summary at the end.
const batchPrintIds: string[] = [];
const batchFailed: string[] = [];
let batched = false;
const applyResult = (result: { uploaded: number; failed: string[]; prints?: Print[]; batched?: boolean }) => {
uploaded += result.uploaded;
failed.push(...result.failed);
if (result.prints) prints.push(...result.prints);
if (result.batched) {
batched = true;
batchPrintIds.push(...(result.prints ?? []).map((p) => p.id));
batchFailed.push(...result.failed);
}
};
if (normalEntries.length) {
if (isFlatFileSet(normalEntries)) {
@@ -164,6 +175,13 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
},
});
}
if (batched) {
await printsApi.uploadSummary(batchPrintIds, batchFailed).catch((err) => {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else console.error("Upload summary failed", err);
});
}
void refreshNotifications();
if (uploaded) {
onUploaded();
if (uploaded === 1 && prints.length === 1) {
@@ -407,6 +425,8 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
alert(message);
} finally {
setImporting(false);
// The import's notification is already written; show it on the bell now, not at the next poll.
void refreshNotifications();
}
};
+16 -1
View File
@@ -248,7 +248,22 @@
"hoursAgo_one": "{{count}} hour ago",
"hoursAgo_other": "{{count}} hours ago",
"daysAgo_one": "{{count}} day ago",
"daysAgo_other": "{{count}} days ago"
"daysAgo_other": "{{count}} days ago",
"clearAll": "Clear all",
"clearAllConfirm": "Delete all your notifications? This can't be undone.",
"viewAll": "View all",
"delete": "Delete notification",
"today": "Today",
"yesterday": "Yesterday",
"loadFailed": "Couldn't load notifications.",
"retention": "Read notifications are deleted after 30 days, unread ones after 90.",
"tabAll": "All",
"tabUnread": "Unread",
"tabSystem": "System",
"emptyUnread": "You're all caught up.",
"emptySystem": "No announcements from Thingport.",
"clearUnreadConfirm": "Delete all unread notifications? This can't be undone.",
"clearSystemConfirm": "Delete all system notifications? They won't be sent again. This can't be undone."
},
"search": {
"placeholder": "Search models, collections, tags…",
+16 -1
View File
@@ -248,7 +248,22 @@
"hoursAgo_one": "prieš {{count}} valandą",
"hoursAgo_other": "prieš {{count}} valandų",
"daysAgo_one": "prieš {{count}} dieną",
"daysAgo_other": "prieš {{count}} dienų"
"daysAgo_other": "prieš {{count}} dienų",
"clearAll": "Išvalyti viską",
"clearAllConfirm": "Ištrinti visus pranešimus? To nebus galima atšaukti.",
"viewAll": "Rodyti visus",
"delete": "Ištrinti pranešimą",
"today": "Šiandien",
"yesterday": "Vakar",
"loadFailed": "Nepavyko įkelti pranešimų.",
"retention": "Perskaityti pranešimai ištrinami po 30 dienų, neperskaityti – po 90.",
"tabAll": "Visi",
"tabUnread": "Neperskaityti",
"tabSystem": "Sistema",
"emptyUnread": "Viskas perskaityta.",
"emptySystem": "Thingport pranešimų nėra.",
"clearUnreadConfirm": "Ištrinti visus neperskaitytus pranešimus? To nebus galima atšaukti.",
"clearSystemConfirm": "Ištrinti visus sistemos pranešimus? Jie nebus siunčiami iš naujo. To nebus galima atšaukti."
},
"search": {
"placeholder": "Ieškoti modelių, kolekcijų, žymų…",
@@ -0,0 +1,112 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import type { Notification, NotificationFilter } from "../../api/notifications";
import { useConfirm } from "../../components/ConfirmProvider";
import { ListRowSkeletons } from "../../components/ListSkeleton";
import NotificationList from "../../components/notifications/NotificationList";
import NotificationTabs, { clearConfirmKey, emptyKey } from "../../components/notifications/NotificationTabs";
import { useNotificationFeed, useOpenedSince } from "../../components/notifications/useNotificationFeed";
type Props = {
onUnauthorized?: () => void;
};
const PAGE_SIZE = 20;
function tabFrom(value: string | null): NotificationFilter {
if (value === "unread") return "unread";
if (value === "system") return "system";
return "all";
}
/** Every notification, a page at a time as you scroll. */
export default function NotificationsPage({ onUnauthorized }: Props) {
const { t } = useTranslation("app");
const navigate = useNavigate();
const confirmDialog = useConfirm();
const [searchParams, setSearchParams] = useSearchParams();
const filter = tabFrom(searchParams.get("tab"));
const opened = useOpenedSince();
const feed = useNotificationFeed(filter, PAGE_SIZE, opened, onUnauthorized);
const sentinelRef = React.useRef<HTMLDivElement | null>(null);
const { loadMore, done, failed } = feed;
React.useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || done || failed) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) loadMore();
},
{ rootMargin: "300px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [loadMore, done, failed]);
const setFilter = (next: NotificationFilter) =>
setSearchParams(next === "all" ? {} : { tab: next }, { replace: true });
const handleOpen = (notification: Notification) => {
if (notification.internal_path) navigate(notification.internal_path);
};
const handleClear = async () => {
const confirmed = await confirmDialog({
message: t(clearConfirmKey(filter)),
confirmLabel: t("notifications.clearAll"),
destructive: true,
});
if (confirmed) void feed.clear();
};
const firstLoad = feed.loading && feed.items.length === 0;
return (
<Stack spacing={2}>
<Stack direction="row" alignItems="center" spacing={1} flexWrap="wrap" useFlexGap>
<Typography variant="body2" color="text.secondary" sx={{ flex: 1, minWidth: 220 }}>
{t("notifications.retention")}
</Typography>
{feed.items.length > 0 && (
<Button color="error" onClick={() => void handleClear()} sx={{ flexShrink: 0 }}>
{t("notifications.clearAll")}
</Button>
)}
</Stack>
{feed.failed && <Alert severity="error">{t("notifications.loadFailed")}</Alert>}
<Paper variant="outlined" sx={{ overflow: "hidden" }}>
<NotificationTabs value={filter} onChange={setFilter} />
<Box sx={{ borderTop: 1, borderColor: "divider", p: 1.5 }}>
{firstLoad ? (
<ListRowSkeletons count={6} lead="circle" leadSize={36} twoLines />
) : feed.items.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: "center", py: 4 }}>
{t(emptyKey(filter))}
</Typography>
) : (
<NotificationList
items={feed.items}
since={opened.since}
onOpen={handleOpen}
onDelete={(n) => void feed.remove(n)}
/>
)}
{!feed.done && !firstLoad && (
<Box ref={sentinelRef} sx={{ display: "flex", justifyContent: "center", py: 2 }}>
{feed.loading && <CircularProgress size={20} />}
</Box>
)}
</Box>
</Paper>
</Stack>
);
}
+16 -8
View File
@@ -168,18 +168,25 @@ export function uploadEntriesToCategory(
return uploadEach(entries, categoryResolver(parentCategoryId), onUnauthorized);
}
// A folder pick isn't filtered by the file picker, so skip what couldn't become a model on its own.
const uploadable = (entries: UploadEntry[]) => entries.filter((e) => UPLOAD_EXTS.includes(extOf(e.file.name)));
/** `batched`: more than one request, each sent without a notification; the caller sends the summary.
* A lone request notifies by itself, so it's recorded even if the page closes mid-upload. */
async function uploadEach(
entries: UploadEntry[],
resolveCategory: ReturnType<typeof categoryResolver>,
onUnauthorized?: () => void,
partOfBatch = false,
) {
const failed: string[] = [];
let uploaded = 0;
const uploadedEntries: UploadEntry[] = [];
const prints: Print[] = [];
const todo = uploadable(entries);
const batched = partOfBatch || todo.length > 1;
// A folder pick isn't filtered by the file picker, so skip what couldn't become a model on its own.
for (const entry of entries.filter((e) => UPLOAD_EXTS.includes(extOf(e.file.name)))) {
for (const entry of todo) {
try {
const segments = splitPath(entry);
let categoryId: string | null;
@@ -192,7 +199,7 @@ async function uploadEach(
continue;
}
// Folder-tree leaves are always single-plate prints.
const result = await printsApi.upload([entry.file], { category_id: categoryId || undefined });
const result = await printsApi.upload([entry.file], { category_id: categoryId || undefined, notify: !batched });
uploaded += 1;
uploadedEntries.push(entry);
prints.push(...result.prints);
@@ -205,7 +212,7 @@ async function uploadEach(
failed.push(failureLabel(entry.file.name, err));
}
}
return { uploaded, failed, uploadedEntries, prints };
return { uploaded, failed, uploadedEntries, prints, batched };
}
/**
@@ -237,6 +244,7 @@ export async function uploadFoldersAsModels(
const uploadedEntries: UploadEntry[] = [];
const prints: Print[] = [];
const resolveCategory = categoryResolver(parentCategoryId);
const batched = modelGroups.length + uploadable(rest).length > 1;
for (const group of modelGroups) {
const folderName = group.segments[group.segments.length - 1];
@@ -255,7 +263,7 @@ export async function uploadFoldersAsModels(
}
const result = await printsApi.upload(
files.map((e) => e.file),
{ title: folderName, category_id: categoryId || undefined, mode: "multiplate" },
{ title: folderName, category_id: categoryId || undefined, mode: "multiplate", notify: !batched },
);
uploaded += 1;
uploadedEntries.push(...files);
@@ -279,7 +287,7 @@ export async function uploadFoldersAsModels(
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return { uploaded, failed, uploadedEntries, prints };
return { uploaded, failed, uploadedEntries, prints, batched };
}
console.error("Upload failed for", folderName, err);
failed.push(failureLabel(folderName, err));
@@ -287,11 +295,11 @@ export async function uploadFoldersAsModels(
}
if (rest.length) {
const result = await uploadEach(rest, resolveCategory, onUnauthorized);
const result = await uploadEach(rest, resolveCategory, onUnauthorized, batched);
uploaded += result.uploaded;
failed.push(...result.failed);
uploadedEntries.push(...result.uploadedEntries);
prints.push(...result.prints);
}
return { uploaded, failed, uploadedEntries, prints };
return { uploaded, failed, uploadedEntries, prints, batched };
}
+12
View File
@@ -0,0 +1,12 @@
{
"notifications": [
{
"id": "2026-10-welcome-system-notifications",
"title": "Thank you for using Thingport",
"body": "Thanks for using Thingport! From now on, major updates and new features will be announced here, in the System tab of your notifications.",
"url": "https://github.com/TautvydasDerzinskas/Thingport",
"published_at": "2026-10-11T12:00:00Z",
"audience": "all"
}
]
}