Implement session tracking product syncs
This commit is contained in:
+37
-23
@@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user