Implement session tracking product syncs

This commit is contained in:
Dominic Ferrando
2026-07-04 00:13:25 -04:00
parent 09667a0a0a
commit 87df874f75
3 changed files with 112 additions and 50 deletions
+37 -23
View File
@@ -24,6 +24,7 @@ import serverTiming from "@elysia/server-timing";
import jwt from "@elysia/jwt"; import jwt from "@elysia/jwt";
import { SyncService } from "./services/sync"; import { SyncService } from "./services/sync";
import cluster from "node:cluster"; import cluster from "node:cluster";
import { randomUUIDv7 } from "bun";
// SERVICES // SERVICES
// ----------- // -----------
@@ -104,7 +105,7 @@ export const app = new Elysia()
if (body.password !== env.ADMIN_PASSWORD) throw status(401, "Invalid credentials"); if (body.password !== env.ADMIN_PASSWORD) throw status(401, "Invalid credentials");
auth.set({ auth.set({
value: await jwt.sign({ id: "admin", exp: "7d" }), value: await jwt.sign({ sessionId: randomUUIDv7(), exp: "7d" }),
path: "/", path: "/",
maxAge: 60 * 60 * 24 * 7, maxAge: 60 * 60 * 24 * 7,
sameSite: "lax", sameSite: "lax",
@@ -115,17 +116,13 @@ export const app = new Elysia()
undefined undefined
}); });
}, { }, {
body: t.Object({ body: t.Object({ password: t.String() }),
password: t.String()
}),
cookie: t.Cookie({ auth: t.Optional(t.String()) }) cookie: t.Cookie({ auth: t.Optional(t.String()) })
}) })
// CALCULATOR // CALCULATOR
.post("/calculate", async ({ body }) => { .post("/calculate", async ({ body }) => {
return { return { levels: levelCalculator.calculate(body.player, body.activityPerformances) };
levels: levelCalculator.calculate(body.player, body.activityPerformances),
};
}, { }, {
body: t.Object({ body: t.Object({
player: PlayerSchema, player: PlayerSchema,
@@ -134,29 +131,34 @@ export const app = new Elysia()
}) })
// COMMERCE // COMMERCE
.group("/products", (app) => .group("/products",
app { cookie: t.Cookie({ auth: t.Optional(t.String()) }) },
.guard({ cookie: t.Cookie({ auth: t.Optional(t.String()) }) }) (app) => app
.onBeforeHandle(async ({ jwt, cookie: { auth } }) => { .resolve(async ({ jwt, cookie: { auth } }) => {
const token = auth.value && await jwt.verify(auth.value); const token = auth.value && await jwt.verify(auth.value);
if (!token) throw status(401, "Unauthorized"); if (!token || !token.sessionId) throw status(401, "Unauthorized");
return { sessionId: token.sessionId.toString() };
}) })
.group("/sync", (app) => .group("/sync", (app) =>
app app
// Sync status // Sync status
.get("/", async ({ }) => { .get("/", async ({ sessionId }) => {
const activeSync = await syncService.getActiveSync(); const latestSync = await syncService.getLatestSync(sessionId);
if (!activeSync) throw new NotFoundError("No sync in progress"); if (!latestSync) throw new NotFoundError("No sync found");
return { return {
startDate: activeSync.started_at, startDate: latestSync.started_at,
syncingPrintfulProductIds: activeSync.syncing_printful_product_ids status: syncService.deriveSyncStatus(latestSync),
syncingPrintfulProductIds: latestSync.syncing_printful_product_ids
} }
}) })
// Run sync // Run sync
.post("/:printfulProductId?", async ({ params: { printfulProductId } }) => { .post("/:printfulProductId?", async ({ params: { printfulProductId }, sessionId }) => {
const printfulProductIds = printfulProductId ? [printfulProductId] : null; await syncService.sync({
log.debug({ printfulProductIds }); session: { id: sessionId, name: "Portal" },
await syncService.sync({ filter: { printfulProductIds } }); filter: {
printfulProductIds: printfulProductId ? [printfulProductId] : null
}
});
}, { params: t.Object({ printfulProductId: t.Optional(t.Numeric()) }) }), }, { params: t.Object({ printfulProductId: t.Optional(t.Numeric()) }) }),
) )
.get("/:printfulProductId", async ({ params }) => { .get("/:printfulProductId", async ({ params }) => {
@@ -184,7 +186,12 @@ export const app = new Elysia()
const printfulProduct = payload.data.sync_product; const printfulProduct = payload.data.sync_product;
log.info({ productId: printfulProduct.id }, "printful webhook: product updated"); log.info({ productId: printfulProduct.id }, "printful webhook: product updated");
await syncService.sync({ filter: { printfulProductIds: [printfulProduct.id] } }); await syncService.sync({
session: { id: randomUUIDv7(), name: "Printful" },
filter: {
printfulProductIds: [printfulProduct.id]
}
});
break; break;
} }
case Printful.Webhook.Event.ProductDeleted: { case Printful.Webhook.Event.ProductDeleted: {
@@ -257,7 +264,14 @@ export const app = new Elysia()
app.listen(3000, async () => { app.listen(3000, async () => {
if (cluster.worker?.id === 1) { if (cluster.worker?.id === 1) {
log.info({ port: 3000 }, "server started") log.info({ port: 3000 }, "server started")
await syncService.syncNext().catch((err) => log.error({ err }, "failed to resume product sync queue"));
try {
const frontQueuedSync = await syncService.queue.front();
if (frontQueuedSync) await syncService.sync(frontQueuedSync);
}
catch (err) {
log.error({ err }, "failed to resume product sync queue")
}
} }
}); });
+62 -19
View File
@@ -2,9 +2,17 @@ import { PrintfulClient, ProductSyncer, WebflowClient, type ProductSyncerOptions
import { log } from "../util"; import { log } from "../util";
import { db } from "../database/db"; import { db } from "../database/db";
import { DatabaseError } from "pg"; import { DatabaseError } from "pg";
import type { Insertable, Selectable } from "kysely";
import type { ProductSyncs } from "../database/out/db";
type SyncStatus = "queued" | "active" | "failed" | "succeeded";
type QueuedSync = { type QueuedSync = {
id?: string | null, id?: string,
session: {
name: string,
id: string
},
filter: ProductSyncerOptions["filter"] filter: ProductSyncerOptions["filter"]
} }
@@ -23,11 +31,13 @@ export class SyncService {
let id = queuedSync.id ?? null; let id = queuedSync.id ?? null;
let freedSlot = false; let freedSlot = false;
try { try {
log.info("starting sync run"); log.info({ session: queuedSync.session }, "starting sync run");
await this.productSyncer.sync({ await this.productSyncer.sync({
filter: queuedSync.filter, filter: queuedSync.filter,
onStart: async (startDate) => { onStart: async (startDate) => {
const values = { const values: Insertable<ProductSyncs> = {
session_id: queuedSync.session.id,
session_name: queuedSync.session.name,
started_at: startDate, started_at: startDate,
printful_product_id_filter: queuedSync.filter?.printfulProductIds ?? null, printful_product_id_filter: queuedSync.filter?.printfulProductIds ?? null,
}; };
@@ -103,34 +113,55 @@ export class SyncService {
} }
} }
finally { finally {
if (freedSlot) if (freedSlot) {
this.syncNext().catch((err) => log.error({ err }, "failed to process next queued sync")); try {
const frontQueuedSync = await this.queue.front();
if (frontQueuedSync) await this.sync(frontQueuedSync);
}
catch (err) {
log.error({ err }, "failed to process next queued sync")
}
}
} }
} }
async syncNext() { async getActiveSync(sessionId?: string) {
const nextSync = await this.queue.front();
if (nextSync) {
await this.sync({
id: nextSync.id,
filter: { printfulProductIds: nextSync.printful_product_id_filter }
});
}
}
async getActiveSync() {
return await db.selectFrom("product_syncs") return await db.selectFrom("product_syncs")
.selectAll() .selectAll()
.$if(sessionId !== undefined, (qb) =>
qb.where("session_id", "=", sessionId!)
)
.where("started_at", "is not", null) .where("started_at", "is not", null)
.where("ended_at", "is", null) .where("ended_at", "is", null)
.limit(1) .limit(1)
.executeTakeFirst(); .executeTakeFirst();
} }
async getLatestSync(sessionId?: string) {
return await db.selectFrom("product_syncs")
.selectAll()
.$if(sessionId !== undefined, (qb) =>
qb.where("session_id", "=", sessionId!)
)
.orderBy("created_at", "desc")
.limit(1)
.executeTakeFirst();
}
deriveSyncStatus(sync: Selectable<ProductSyncs>): SyncStatus {
if (!sync.started_at) return "queued";
if (!sync.ended_at) return "active";
return sync.has_failed ? "failed" : "succeeded";
}
} }
class SyncQueue { class SyncQueue {
async enqueue(queuedSync: QueuedSync) { async enqueue(queuedSync: QueuedSync) {
const values = { printful_product_id_filter: queuedSync.filter?.printfulProductIds }; const values: Insertable<ProductSyncs> = {
session_id: queuedSync.session.id,
session_name: queuedSync.session.name,
printful_product_id_filter: queuedSync.filter?.printfulProductIds ?? null
};
if (queuedSync.id) { if (queuedSync.id) {
await db.updateTable("product_syncs") await db.updateTable("product_syncs")
.set(values) .set(values)
@@ -144,12 +175,24 @@ class SyncQueue {
} }
} }
async front() { async front(): Promise<QueuedSync | undefined> {
return await db.selectFrom("product_syncs") const result = await db.selectFrom("product_syncs")
.selectAll() .selectAll()
.where("started_at", "is", null) .where("started_at", "is", null)
.orderBy("created_at", "asc") .orderBy("created_at", "asc")
.limit(1) .limit(1)
.executeTakeFirst(); .executeTakeFirst();
if (!result) return;
return {
id: result.id,
session: {
id: result.session_id,
name: result.session_name
},
filter: {
printfulProductIds: result.printful_product_id_filter
}
}
} }
} }
@@ -3,18 +3,23 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { api } from "$lib/api.js"; import { api } from "$lib/api.js";
type SyncStatus = "queued" | "active" | "failed" | "succeeded" | "none";
const { data } = $props(); const { data } = $props();
const synchronizer = $state({ const synchronizer = $state({
isRunning: false, status: "none" as SyncStatus,
syncingPrintfulProductIds: [] as number[], syncingPrintfulProductIds: [] as number[],
get isInProgress() {
return this.status === "active" || this.status === "queued";
},
poll: async function () { poll: async function () {
const res = await api.products.sync.get(); const res = await api.products.sync.get();
if (res.error) { if (res.error) {
synchronizer.isRunning = false; synchronizer.status = "none";
synchronizer.syncingPrintfulProductIds = []; synchronizer.syncingPrintfulProductIds = [];
} else { } else {
synchronizer.isRunning = true; synchronizer.status = res.data.status;
synchronizer.syncingPrintfulProductIds = synchronizer.syncingPrintfulProductIds =
res.data.syncingPrintfulProductIds; res.data.syncingPrintfulProductIds;
} }
@@ -22,7 +27,7 @@
startPolling: () => { startPolling: () => {
const intervalId = setInterval(async () => { const intervalId = setInterval(async () => {
await synchronizer.poll(); await synchronizer.poll();
if (!synchronizer.isRunning) clearInterval(intervalId); if (!synchronizer.isInProgress) clearInterval(intervalId);
}, 100); }, 100);
}, },
syncAll: async () => { syncAll: async () => {
@@ -37,7 +42,7 @@
onMount(async () => { onMount(async () => {
await synchronizer.poll(); await synchronizer.poll();
if (synchronizer.isRunning) { if (synchronizer.isInProgress) {
synchronizer.startPolling(); synchronizer.startPolling();
} }
}); });
@@ -56,11 +61,11 @@
<p>Loading...</p> <p>Loading...</p>
{:then printfulProducts} {:then printfulProducts}
<button <button
disabled={synchronizer.isRunning} disabled={synchronizer.isInProgress}
onclick={() => synchronizer.syncAll()} onclick={() => synchronizer.syncAll()}
class="btn btn-xl mb-5" class="btn btn-xl mb-5"
> >
{#if synchronizer.isRunning} {#if synchronizer.isInProgress}
Syncing... Syncing...
{:else} {:else}
Sync all Sync all
@@ -113,7 +118,7 @@
</td> </td>
<td> <td>
<button <button
disabled={synchronizer.isRunning} disabled={synchronizer.status === "active"}
onclick={() => onclick={() =>
synchronizer.sync(printfulProduct.id)} synchronizer.sync(printfulProduct.id)}
class="btn" class="btn"