@access-dlsu/leapify 0.260531.1 → 0.260601.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts.map +1 -1
- package/dist/auth/auth.d.ts +2 -2
- package/dist/client/auth.d.ts +41 -41
- package/dist/client/index.cjs +9 -0
- package/dist/client/index.cjs.map +1 -1
- package/dist/client/index.d.ts +7 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +9 -0
- package/dist/client/index.js.map +1 -1
- package/dist/cron/reconcile-slots.d.ts.map +1 -1
- package/dist/index.cjs +343 -222
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +346 -225
- package/dist/index.js.map +1 -1
- package/dist/lib/middleware/cors.d.ts.map +1 -1
- package/dist/lib/middleware/referer-guard.d.ts +1 -1
- package/dist/lib/middleware/referer-guard.d.ts.map +1 -1
- package/dist/routes/internal/batch-release.d.ts +4 -0
- package/dist/routes/internal/batch-release.d.ts.map +1 -0
- package/dist/routes/internal/reconcile-slots.d.ts +4 -0
- package/dist/routes/internal/reconcile-slots.d.ts.map +1 -0
- package/dist/routes/internal/reminder-emails.d.ts +4 -0
- package/dist/routes/internal/reminder-emails.d.ts.map +1 -0
- package/dist/routes/internal/renew-watches.d.ts +4 -0
- package/dist/routes/internal/renew-watches.d.ts.map +1 -0
- package/dist/routes/site-config.d.ts +2 -2
- package/dist/routes/site-config.d.ts.map +1 -1
- package/dist/services/gforms.d.ts.map +1 -1
- package/dist/worker.js +539 -418
- package/dist/worker.js.map +1 -1
- package/package.json +155 -155
package/dist/index.js
CHANGED
|
@@ -2,13 +2,13 @@ import { createTurnstileMiddleware, TURNSTILE_VERIFY_PATH, handleTurnstileVerify
|
|
|
2
2
|
import { __export } from './chunk-PZ5AY32C.js';
|
|
3
3
|
import { Hono } from 'hono';
|
|
4
4
|
import { cors } from 'hono/cors';
|
|
5
|
+
import { drizzle } from 'drizzle-orm/d1';
|
|
6
|
+
import { sqliteTable, integer, text, index, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
|
7
|
+
import { sql, relations, eq, and, count, isNotNull, lte } from 'drizzle-orm';
|
|
5
8
|
import { createMiddleware } from 'hono/factory';
|
|
6
9
|
import { betterAuth } from 'better-auth';
|
|
7
10
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
|
8
11
|
import { bearer } from 'better-auth/plugins';
|
|
9
|
-
import { sql, relations, eq, and, count, lte } from 'drizzle-orm';
|
|
10
|
-
import { drizzle } from 'drizzle-orm/d1';
|
|
11
|
-
import { sqliteTable, integer, text, index, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
|
12
12
|
import { zValidator } from '@hono/zod-validator';
|
|
13
13
|
import { z } from 'zod';
|
|
14
14
|
|
|
@@ -20,6 +20,8 @@ var LeapifyError = class extends Error {
|
|
|
20
20
|
this.code = code;
|
|
21
21
|
this.name = "LeapifyError";
|
|
22
22
|
}
|
|
23
|
+
statusCode;
|
|
24
|
+
code;
|
|
23
25
|
};
|
|
24
26
|
var unauthorized = (message = "Unauthorized") => new LeapifyError(401, "UNAUTHORIZED", message);
|
|
25
27
|
var domainRestricted = () => new LeapifyError(
|
|
@@ -48,58 +50,6 @@ var errorHandler = (err, c) => {
|
|
|
48
50
|
500
|
|
49
51
|
);
|
|
50
52
|
};
|
|
51
|
-
function createCorsMiddleware(allowedOrigins) {
|
|
52
|
-
return async (c, next) => {
|
|
53
|
-
const origin = c.req.header("origin");
|
|
54
|
-
const dynamicOriginsJson = await c.env.KV.get("config:allowed_origins", "json");
|
|
55
|
-
const currentAllowedOrigins = dynamicOriginsJson ?? allowedOrigins;
|
|
56
|
-
if (c.req.path.startsWith("/api/uploads/images")) {
|
|
57
|
-
c.header("Access-Control-Allow-Origin", "*");
|
|
58
|
-
c.header("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
59
|
-
if (c.req.method === "OPTIONS") {
|
|
60
|
-
return c.body(null, 204);
|
|
61
|
-
}
|
|
62
|
-
return next();
|
|
63
|
-
}
|
|
64
|
-
if (!c.req.path.startsWith("/health") && !c.req.path.startsWith("/api/auth") && !c.req.path.startsWith("/internal") && origin && !currentAllowedOrigins.includes("*") && !currentAllowedOrigins.includes(origin)) {
|
|
65
|
-
return c.json(
|
|
66
|
-
{
|
|
67
|
-
error: {
|
|
68
|
-
code: "DOMAIN_RESTRICTED",
|
|
69
|
-
message: `Origin ${origin} is not allowed`
|
|
70
|
-
}
|
|
71
|
-
},
|
|
72
|
-
403
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
const honoCors = cors({
|
|
76
|
-
origin: currentAllowedOrigins,
|
|
77
|
-
allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
|
78
|
-
allowHeaders: ["Content-Type", "Authorization"],
|
|
79
|
-
exposeHeaders: ["ETag", "Last-Modified", "Cache-Control"],
|
|
80
|
-
maxAge: 86400,
|
|
81
|
-
credentials: true
|
|
82
|
-
});
|
|
83
|
-
return honoCors(c, next);
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
function createRefererGuard(allowedOrigins) {
|
|
87
|
-
const MUTATION_METHODS = /* @__PURE__ */ new Set(["POST", "PATCH", "PUT", "DELETE"]);
|
|
88
|
-
const SKIP_PREFIXES = ["/health", "/internal", "/api/auth", "/.well-known"];
|
|
89
|
-
return createMiddleware(async (c, next) => {
|
|
90
|
-
if (!MUTATION_METHODS.has(c.req.method)) return next();
|
|
91
|
-
if (SKIP_PREFIXES.some((p) => c.req.path.startsWith(p))) return next();
|
|
92
|
-
const dynamicOriginsJson = await c.env.KV.get("config:allowed_origins", "json");
|
|
93
|
-
const currentAllowedOrigins = dynamicOriginsJson ?? allowedOrigins;
|
|
94
|
-
if (currentAllowedOrigins.includes("*")) return next();
|
|
95
|
-
const referer = c.req.header("referer") ?? "";
|
|
96
|
-
const isAllowed = currentAllowedOrigins.some((origin) => referer.startsWith(origin));
|
|
97
|
-
if (!isAllowed) {
|
|
98
|
-
throw forbidden("Request origin not permitted");
|
|
99
|
-
}
|
|
100
|
-
return next();
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
53
|
|
|
104
54
|
// src/db/schema/index.ts
|
|
105
55
|
var schema_exports = {};
|
|
@@ -332,8 +282,112 @@ var authVerification = sqliteTable(
|
|
|
332
282
|
function createDb(d1) {
|
|
333
283
|
return drizzle(d1, { schema: schema_exports });
|
|
334
284
|
}
|
|
335
|
-
|
|
336
|
-
|
|
285
|
+
async function getOriginsFromDb(env) {
|
|
286
|
+
try {
|
|
287
|
+
const db = createDb(env.DB);
|
|
288
|
+
const row = await db.query.siteConfig.findFirst({
|
|
289
|
+
where: eq(siteConfig.key, "allowed_origins")
|
|
290
|
+
});
|
|
291
|
+
if (row) return JSON.parse(row.value);
|
|
292
|
+
} catch {
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
function createCorsMiddleware(allowedOrigins) {
|
|
297
|
+
return async (c, next) => {
|
|
298
|
+
const origin = c.req.header("origin");
|
|
299
|
+
const dynamicOriginsJson = await c.env.KV.get(
|
|
300
|
+
"config:allowed_origins",
|
|
301
|
+
"json"
|
|
302
|
+
);
|
|
303
|
+
let currentAllowedOrigins = dynamicOriginsJson ?? allowedOrigins;
|
|
304
|
+
if (!dynamicOriginsJson) {
|
|
305
|
+
const dbOrigins = await getOriginsFromDb(c.env);
|
|
306
|
+
if (dbOrigins) {
|
|
307
|
+
currentAllowedOrigins = dbOrigins;
|
|
308
|
+
await c.env.KV.put(
|
|
309
|
+
"config:allowed_origins",
|
|
310
|
+
JSON.stringify(dbOrigins),
|
|
311
|
+
{ expirationTtl: 86400 }
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (c.req.path.startsWith("/api/uploads/images")) {
|
|
316
|
+
c.header("Access-Control-Allow-Origin", "*");
|
|
317
|
+
c.header("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
318
|
+
if (c.req.method === "OPTIONS") {
|
|
319
|
+
return c.body(null, 204);
|
|
320
|
+
}
|
|
321
|
+
return next();
|
|
322
|
+
}
|
|
323
|
+
if (!c.req.path.startsWith("/health") && !c.req.path.startsWith("/api/auth") && !c.req.path.startsWith("/internal") && origin && !currentAllowedOrigins.includes("*") && !currentAllowedOrigins.includes(origin) && origin !== new URL(c.req.url).origin) {
|
|
324
|
+
return c.json(
|
|
325
|
+
{
|
|
326
|
+
error: {
|
|
327
|
+
code: "DOMAIN_RESTRICTED",
|
|
328
|
+
message: `Origin ${origin} is not allowed`
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
403
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
const honoCors = cors({
|
|
335
|
+
origin: currentAllowedOrigins,
|
|
336
|
+
allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
|
337
|
+
allowHeaders: ["Content-Type", "Authorization"],
|
|
338
|
+
exposeHeaders: ["ETag", "Last-Modified", "Cache-Control"],
|
|
339
|
+
maxAge: 86400,
|
|
340
|
+
credentials: true
|
|
341
|
+
});
|
|
342
|
+
return honoCors(c, next);
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
async function getOriginsFromDb2(env) {
|
|
346
|
+
try {
|
|
347
|
+
const db = createDb(env.DB);
|
|
348
|
+
const row = await db.query.siteConfig.findFirst({
|
|
349
|
+
where: eq(siteConfig.key, "allowed_origins")
|
|
350
|
+
});
|
|
351
|
+
if (row) return JSON.parse(row.value);
|
|
352
|
+
} catch {
|
|
353
|
+
}
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
function createRefererGuard(allowedOrigins) {
|
|
357
|
+
const MUTATION_METHODS = /* @__PURE__ */ new Set(["POST", "PATCH", "PUT", "DELETE"]);
|
|
358
|
+
const SKIP_PREFIXES = ["/health", "/internal", "/api/auth", "/.well-known"];
|
|
359
|
+
return createMiddleware(async (c, next) => {
|
|
360
|
+
if (!MUTATION_METHODS.has(c.req.method)) return next();
|
|
361
|
+
if (SKIP_PREFIXES.some((p) => c.req.path.startsWith(p))) return next();
|
|
362
|
+
const dynamicOriginsJson = await c.env.KV.get(
|
|
363
|
+
"config:allowed_origins",
|
|
364
|
+
"json"
|
|
365
|
+
);
|
|
366
|
+
let currentAllowedOrigins = dynamicOriginsJson ?? allowedOrigins;
|
|
367
|
+
if (!dynamicOriginsJson) {
|
|
368
|
+
const dbOrigins = await getOriginsFromDb2(c.env);
|
|
369
|
+
if (dbOrigins) {
|
|
370
|
+
currentAllowedOrigins = dbOrigins;
|
|
371
|
+
await c.env.KV.put(
|
|
372
|
+
"config:allowed_origins",
|
|
373
|
+
JSON.stringify(dbOrigins),
|
|
374
|
+
{ expirationTtl: 86400 }
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (currentAllowedOrigins.includes("*")) return next();
|
|
379
|
+
const referer = c.req.header("referer") ?? "";
|
|
380
|
+
const requestOrigin = new URL(c.req.url).origin;
|
|
381
|
+
if (referer.startsWith(requestOrigin)) return next();
|
|
382
|
+
const isAllowed = currentAllowedOrigins.some(
|
|
383
|
+
(origin) => referer.startsWith(origin)
|
|
384
|
+
);
|
|
385
|
+
if (!isAllowed) {
|
|
386
|
+
throw forbidden("Request origin not permitted");
|
|
387
|
+
}
|
|
388
|
+
return next();
|
|
389
|
+
});
|
|
390
|
+
}
|
|
337
391
|
var DLSU_DOMAIN = "@dlsu.edu.ph";
|
|
338
392
|
function createAuth(env) {
|
|
339
393
|
const db = createDb(env.DB);
|
|
@@ -629,7 +683,14 @@ healthRoute.get("/", async (c) => {
|
|
|
629
683
|
const env = c.env;
|
|
630
684
|
const hasSes = Boolean(env.SES_REGION) && Boolean(env.SES_ACCESS_KEY_ID) && Boolean(env.SES_SECRET_ACCESS_KEY);
|
|
631
685
|
const hasResend = Boolean(env.RESEND_API_KEY);
|
|
632
|
-
|
|
686
|
+
let hasGForms = false;
|
|
687
|
+
if (env.GFORMS_SERVICE_ACCOUNT_JSON) {
|
|
688
|
+
try {
|
|
689
|
+
const parsed = JSON.parse(env.GFORMS_SERVICE_ACCOUNT_JSON);
|
|
690
|
+
hasGForms = Boolean(parsed.client_email && parsed.private_key);
|
|
691
|
+
} catch {
|
|
692
|
+
}
|
|
693
|
+
}
|
|
633
694
|
const probes = [];
|
|
634
695
|
if (hasSes) {
|
|
635
696
|
probes.push(
|
|
@@ -687,6 +748,7 @@ var CacheService = class {
|
|
|
687
748
|
constructor(kv) {
|
|
688
749
|
this.kv = kv;
|
|
689
750
|
}
|
|
751
|
+
kv;
|
|
690
752
|
async get(key) {
|
|
691
753
|
return this.kv.get(key, "json");
|
|
692
754
|
}
|
|
@@ -732,6 +794,8 @@ var SlotsService = class {
|
|
|
732
794
|
this.db = db;
|
|
733
795
|
this.cache = cache;
|
|
734
796
|
}
|
|
797
|
+
db;
|
|
798
|
+
cache;
|
|
735
799
|
kvKey(slug) {
|
|
736
800
|
return `${SLOT_KV_PREFIX}${slug}`;
|
|
737
801
|
}
|
|
@@ -886,7 +950,10 @@ var GFormsService = class {
|
|
|
886
950
|
const response = await fetch(url.toString(), {
|
|
887
951
|
headers: { Authorization: `Bearer ${token}` }
|
|
888
952
|
});
|
|
889
|
-
if (!response.ok)
|
|
953
|
+
if (!response.ok) {
|
|
954
|
+
const err = await response.text();
|
|
955
|
+
throw new Error(`Forms API error: ${response.status} ${err}`);
|
|
956
|
+
}
|
|
890
957
|
const data = await response.json();
|
|
891
958
|
allResponses.push(...data.responses ?? []);
|
|
892
959
|
pageToken = data.nextPageToken;
|
|
@@ -1160,6 +1227,27 @@ classesRoute.get("/:slug/slots", eventsSlotsRateLimit, async (c) => {
|
|
|
1160
1227
|
c.header("Cache-Control", "public, max-age=5, stale-while-revalidate=5");
|
|
1161
1228
|
return c.json({ data: info });
|
|
1162
1229
|
});
|
|
1230
|
+
classesRoute.post("/:slug/reconcile", authMiddleware, adminMiddleware, async (c) => {
|
|
1231
|
+
const { slug } = c.req.param();
|
|
1232
|
+
const db = createDb(c.env.DB);
|
|
1233
|
+
const cache = new CacheService(c.env.KV);
|
|
1234
|
+
const gforms = new GFormsService(c.env.GFORMS_SERVICE_ACCOUNT_JSON);
|
|
1235
|
+
const slots = new SlotsService(db, cache);
|
|
1236
|
+
const event = await db.query.events.findFirst({
|
|
1237
|
+
where: eq(events.slug, slug),
|
|
1238
|
+
columns: { gformsId: true }
|
|
1239
|
+
});
|
|
1240
|
+
if (!event) throw notFound("Event");
|
|
1241
|
+
if (!event.gformsId) return c.json({ error: "No gformsId set for this event" }, 400);
|
|
1242
|
+
try {
|
|
1243
|
+
const googleCount = await gforms.getExactResponseCount(event.gformsId);
|
|
1244
|
+
await slots.correctCount(slug, googleCount);
|
|
1245
|
+
return c.json({ data: { registeredSlots: googleCount } });
|
|
1246
|
+
} catch (err) {
|
|
1247
|
+
const message = err?.message ?? "Failed to fetch from Google Forms API";
|
|
1248
|
+
return c.json({ error: { code: "GFORMS_API_ERROR", message } }, 502);
|
|
1249
|
+
}
|
|
1250
|
+
});
|
|
1163
1251
|
classesRoute.post(
|
|
1164
1252
|
"/",
|
|
1165
1253
|
authMiddleware,
|
|
@@ -1346,7 +1434,7 @@ siteConfigRoute.patch("/:key", authMiddleware, adminMiddleware, async (c) => {
|
|
|
1346
1434
|
set: { value: JSON.stringify(value), updatedAt: now }
|
|
1347
1435
|
});
|
|
1348
1436
|
await c.env.KV.put(`config:${key}`, JSON.stringify(value), {
|
|
1349
|
-
expirationTtl:
|
|
1437
|
+
expirationTtl: 86400
|
|
1350
1438
|
});
|
|
1351
1439
|
return c.json({ data: { key, value } });
|
|
1352
1440
|
});
|
|
@@ -1467,6 +1555,196 @@ async function verifyGoogSignature(body, signature, secret) {
|
|
|
1467
1555
|
return false;
|
|
1468
1556
|
}
|
|
1469
1557
|
}
|
|
1558
|
+
var LOCK_KEY = "cron:reconcile-slots:lock";
|
|
1559
|
+
var LOCK_TTL = 300;
|
|
1560
|
+
async function reconcileSlots(env) {
|
|
1561
|
+
const db = createDb(env.DB);
|
|
1562
|
+
const cache = new CacheService(env.KV);
|
|
1563
|
+
const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
|
|
1564
|
+
const slots = new SlotsService(db, cache);
|
|
1565
|
+
const lock = await cache.get(LOCK_KEY);
|
|
1566
|
+
if (lock) {
|
|
1567
|
+
console.log("[reconcile-slots] Lock held, skipping.");
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
await cache.set(LOCK_KEY, "1", LOCK_TTL);
|
|
1571
|
+
try {
|
|
1572
|
+
const publishedEvents = await db.query.events.findMany({
|
|
1573
|
+
where: isNotNull(events.gformsId),
|
|
1574
|
+
columns: { id: true, slug: true, gformsId: true, registeredSlots: true }
|
|
1575
|
+
});
|
|
1576
|
+
const eventsWithForms = publishedEvents.filter((e) => e.gformsId);
|
|
1577
|
+
let corrected = 0;
|
|
1578
|
+
for (const event of eventsWithForms) {
|
|
1579
|
+
try {
|
|
1580
|
+
const googleCount = await gforms.getExactResponseCount(event.gformsId);
|
|
1581
|
+
const localCount = event.registeredSlots;
|
|
1582
|
+
if (googleCount !== localCount) {
|
|
1583
|
+
console.warn(
|
|
1584
|
+
`[reconcile-slots] Drift on "${event.slug}": local=${localCount}, google=${googleCount}`
|
|
1585
|
+
);
|
|
1586
|
+
await slots.correctCount(event.slug, googleCount);
|
|
1587
|
+
corrected++;
|
|
1588
|
+
}
|
|
1589
|
+
} catch (err) {
|
|
1590
|
+
console.error(`[reconcile-slots] Error checking "${event.slug}":`, err);
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
console.log(
|
|
1594
|
+
`[reconcile-slots] Checked ${eventsWithForms.length} events, corrected ${corrected}.`
|
|
1595
|
+
);
|
|
1596
|
+
} finally {
|
|
1597
|
+
await cache.del(LOCK_KEY);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// src/routes/internal/reconcile-slots.ts
|
|
1602
|
+
var reconcileSlotsRoute = new Hono();
|
|
1603
|
+
reconcileSlotsRoute.post("/", internalMiddleware, async (c) => {
|
|
1604
|
+
await reconcileSlots(c.env);
|
|
1605
|
+
return c.json({ ok: true });
|
|
1606
|
+
});
|
|
1607
|
+
async function batchRelease(env) {
|
|
1608
|
+
const db = createDb(env.DB);
|
|
1609
|
+
const cache = new CacheService(env.KV);
|
|
1610
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1611
|
+
const toPublish = await db.query.events.findMany({
|
|
1612
|
+
where: and(eq(events.status, "queued"), lte(events.releaseAt, now)),
|
|
1613
|
+
columns: { id: true, slug: true }
|
|
1614
|
+
});
|
|
1615
|
+
if (toPublish.length === 0) return;
|
|
1616
|
+
const ids = toPublish.map((e) => e.id);
|
|
1617
|
+
await db.update(events).set({ status: "published", publishedAt: sql`(unixepoch())` }).where(
|
|
1618
|
+
// Drizzle doesn't have inArray for D1; use raw SQL for batch
|
|
1619
|
+
sql`${events.id} IN (${sql.join(
|
|
1620
|
+
ids.map((id) => sql`${id}`),
|
|
1621
|
+
sql`, `
|
|
1622
|
+
)})`
|
|
1623
|
+
);
|
|
1624
|
+
await cache.del("events:list");
|
|
1625
|
+
await cache.del("events:etag");
|
|
1626
|
+
console.log(
|
|
1627
|
+
`[batch-release] Published ${toPublish.length} events:`,
|
|
1628
|
+
toPublish.map((e) => e.slug).join(", ")
|
|
1629
|
+
);
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// src/routes/internal/batch-release.ts
|
|
1633
|
+
var batchReleaseRoute = new Hono();
|
|
1634
|
+
batchReleaseRoute.post("/", internalMiddleware, async (c) => {
|
|
1635
|
+
await batchRelease(c.env);
|
|
1636
|
+
return c.json({ ok: true });
|
|
1637
|
+
});
|
|
1638
|
+
function parseStartTimestamp(dateTime, startTime) {
|
|
1639
|
+
if (!dateTime) return null;
|
|
1640
|
+
const combined = startTime ? `${dateTime} ${startTime}` : dateTime;
|
|
1641
|
+
const ms = Date.parse(combined);
|
|
1642
|
+
return Number.isNaN(ms) ? null : Math.floor(ms / 1e3);
|
|
1643
|
+
}
|
|
1644
|
+
async function reminderEmails(env) {
|
|
1645
|
+
if (!env.EMAIL_QUEUE) {
|
|
1646
|
+
console.warn(
|
|
1647
|
+
"[reminder-emails] EMAIL_QUEUE binding not configured, skipping."
|
|
1648
|
+
);
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
const hasSes = !!(env.SES_REGION && env.SES_ACCESS_KEY_ID && env.SES_SECRET_ACCESS_KEY);
|
|
1652
|
+
const hasResend = !!env.RESEND_API_KEY;
|
|
1653
|
+
if (!hasSes && !hasResend) {
|
|
1654
|
+
console.warn(
|
|
1655
|
+
"[reminder-emails] No email providers configured. Skipping reminders."
|
|
1656
|
+
);
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
const db = createDb(env.DB);
|
|
1660
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1661
|
+
const candidates24h = await db.query.events.findMany({
|
|
1662
|
+
where: and(
|
|
1663
|
+
eq(events.status, "published"),
|
|
1664
|
+
eq(events.reminder24hSent, false)
|
|
1665
|
+
),
|
|
1666
|
+
columns: {
|
|
1667
|
+
id: true,
|
|
1668
|
+
dateTime: true,
|
|
1669
|
+
startTime: true
|
|
1670
|
+
}
|
|
1671
|
+
});
|
|
1672
|
+
for (const event of candidates24h) {
|
|
1673
|
+
const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
|
|
1674
|
+
if (!startsAt) continue;
|
|
1675
|
+
const hoursUntil = (startsAt - now) / 3600;
|
|
1676
|
+
if (hoursUntil <= 25 && hoursUntil >= 23) {
|
|
1677
|
+
await env.EMAIL_QUEUE.send({
|
|
1678
|
+
type: "send_reminder_email",
|
|
1679
|
+
payload: { eventId: event.id, hoursBeforeEvent: 24 }
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
const candidates1h = await db.query.events.findMany({
|
|
1684
|
+
where: and(
|
|
1685
|
+
eq(events.status, "published"),
|
|
1686
|
+
eq(events.reminder1hSent, false)
|
|
1687
|
+
),
|
|
1688
|
+
columns: {
|
|
1689
|
+
id: true,
|
|
1690
|
+
dateTime: true,
|
|
1691
|
+
startTime: true
|
|
1692
|
+
}
|
|
1693
|
+
});
|
|
1694
|
+
for (const event of candidates1h) {
|
|
1695
|
+
const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
|
|
1696
|
+
if (!startsAt) continue;
|
|
1697
|
+
const hoursUntil = (startsAt - now) / 3600;
|
|
1698
|
+
if (hoursUntil <= 1.5 && hoursUntil >= 0) {
|
|
1699
|
+
await env.EMAIL_QUEUE.send({
|
|
1700
|
+
type: "send_reminder_email",
|
|
1701
|
+
payload: { eventId: event.id, hoursBeforeEvent: 1 }
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// src/routes/internal/reminder-emails.ts
|
|
1708
|
+
var reminderEmailsRoute = new Hono();
|
|
1709
|
+
reminderEmailsRoute.post("/", internalMiddleware, async (c) => {
|
|
1710
|
+
await reminderEmails(c.env);
|
|
1711
|
+
return c.json({ ok: true });
|
|
1712
|
+
});
|
|
1713
|
+
var RENEWAL_WINDOW = 86400;
|
|
1714
|
+
async function renewWatches(env) {
|
|
1715
|
+
const db = createDb(env.DB);
|
|
1716
|
+
const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
|
|
1717
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1718
|
+
const threshold = now + RENEWAL_WINDOW;
|
|
1719
|
+
const expiring = await db.query.events.findMany({
|
|
1720
|
+
where: and(
|
|
1721
|
+
eq(events.status, "published"),
|
|
1722
|
+
lte(events.watchExpiresAt, threshold)
|
|
1723
|
+
),
|
|
1724
|
+
columns: { id: true, slug: true, gformsId: true, watchId: true, watchExpiresAt: true }
|
|
1725
|
+
});
|
|
1726
|
+
const watchEvents = expiring.filter((e) => e.gformsId && e.watchId);
|
|
1727
|
+
let renewed = 0;
|
|
1728
|
+
for (const event of watchEvents) {
|
|
1729
|
+
try {
|
|
1730
|
+
const result = await gforms.renewWatch(event.gformsId, event.watchId);
|
|
1731
|
+
const newExpiry = Math.floor(new Date(result.expireTime).getTime() / 1e3);
|
|
1732
|
+
await db.update(events).set({ watchExpiresAt: newExpiry }).where(eq(events.id, event.id));
|
|
1733
|
+
renewed++;
|
|
1734
|
+
console.log(`[renew-watches] Renewed Watch for "${event.slug}", expires ${result.expireTime}`);
|
|
1735
|
+
} catch (err) {
|
|
1736
|
+
console.error(`[renew-watches] Failed to renew Watch for "${event.slug}":`, err);
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
console.log(`[renew-watches] Renewed ${renewed}/${watchEvents.length} watches.`);
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
// src/routes/internal/renew-watches.ts
|
|
1743
|
+
var renewWatchesRoute = new Hono();
|
|
1744
|
+
renewWatchesRoute.post("/", internalMiddleware, async (c) => {
|
|
1745
|
+
await renewWatches(c.env);
|
|
1746
|
+
return c.json({ ok: true });
|
|
1747
|
+
});
|
|
1470
1748
|
var ALLOWED_MIME_TYPES = /* @__PURE__ */ new Set([
|
|
1471
1749
|
"image/jpeg",
|
|
1472
1750
|
"image/png",
|
|
@@ -1733,6 +2011,10 @@ function createApp(options = {}) {
|
|
|
1733
2011
|
app.route("/api/faqs", faqsRoute);
|
|
1734
2012
|
app.route("/api/uploads", uploadsRoute);
|
|
1735
2013
|
app.route("/internal/gforms-webhook", gformsWebhookRoute);
|
|
2014
|
+
app.route("/internal/reconcile-slots", reconcileSlotsRoute);
|
|
2015
|
+
app.route("/internal/batch-release", batchReleaseRoute);
|
|
2016
|
+
app.route("/internal/reminder-emails", reminderEmailsRoute);
|
|
2017
|
+
app.route("/internal/renew-watches", renewWatchesRoute);
|
|
1736
2018
|
app.onError(errorHandler);
|
|
1737
2019
|
app.notFound(
|
|
1738
2020
|
(c) => c.json({ error: { code: "NOT_FOUND", message: "Route not found" } }, 404)
|
|
@@ -1838,6 +2120,7 @@ var SesError = class extends Error {
|
|
|
1838
2120
|
this.status = status;
|
|
1839
2121
|
this.name = "SesError";
|
|
1840
2122
|
}
|
|
2123
|
+
status;
|
|
1841
2124
|
/**
|
|
1842
2125
|
* True for errors that are permanent (not worth retrying via SES again).
|
|
1843
2126
|
* 400 BadRequest, 403 Forbidden, 404 NotFound → non-retryable.
|
|
@@ -2162,168 +2445,6 @@ async function processJob(job, services) {
|
|
|
2162
2445
|
}
|
|
2163
2446
|
}
|
|
2164
2447
|
}
|
|
2165
|
-
async function batchRelease(env) {
|
|
2166
|
-
const db = createDb(env.DB);
|
|
2167
|
-
const cache = new CacheService(env.KV);
|
|
2168
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
2169
|
-
const toPublish = await db.query.events.findMany({
|
|
2170
|
-
where: and(eq(events.status, "queued"), lte(events.releaseAt, now)),
|
|
2171
|
-
columns: { id: true, slug: true }
|
|
2172
|
-
});
|
|
2173
|
-
if (toPublish.length === 0) return;
|
|
2174
|
-
const ids = toPublish.map((e) => e.id);
|
|
2175
|
-
await db.update(events).set({ status: "published", publishedAt: sql`(unixepoch())` }).where(
|
|
2176
|
-
// Drizzle doesn't have inArray for D1; use raw SQL for batch
|
|
2177
|
-
sql`${events.id} IN (${sql.join(
|
|
2178
|
-
ids.map((id) => sql`${id}`),
|
|
2179
|
-
sql`, `
|
|
2180
|
-
)})`
|
|
2181
|
-
);
|
|
2182
|
-
await cache.del("events:list");
|
|
2183
|
-
await cache.del("events:etag");
|
|
2184
|
-
console.log(
|
|
2185
|
-
`[batch-release] Published ${toPublish.length} events:`,
|
|
2186
|
-
toPublish.map((e) => e.slug).join(", ")
|
|
2187
|
-
);
|
|
2188
|
-
}
|
|
2189
|
-
var LOCK_KEY = "cron:reconcile-slots:lock";
|
|
2190
|
-
var LOCK_TTL = 300;
|
|
2191
|
-
async function reconcileSlots(env) {
|
|
2192
|
-
const db = createDb(env.DB);
|
|
2193
|
-
const cache = new CacheService(env.KV);
|
|
2194
|
-
const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
|
|
2195
|
-
const slots = new SlotsService(db, cache);
|
|
2196
|
-
const lock = await cache.get(LOCK_KEY);
|
|
2197
|
-
if (lock) {
|
|
2198
|
-
console.log("[reconcile-slots] Lock held, skipping.");
|
|
2199
|
-
return;
|
|
2200
|
-
}
|
|
2201
|
-
await cache.set(LOCK_KEY, "1", LOCK_TTL);
|
|
2202
|
-
try {
|
|
2203
|
-
const publishedEvents = await db.query.events.findMany({
|
|
2204
|
-
where: eq(events.status, "published"),
|
|
2205
|
-
columns: { id: true, slug: true, gformsId: true, registeredSlots: true }
|
|
2206
|
-
});
|
|
2207
|
-
const eventsWithForms = publishedEvents.filter((e) => e.gformsId);
|
|
2208
|
-
let corrected = 0;
|
|
2209
|
-
for (const event of eventsWithForms) {
|
|
2210
|
-
try {
|
|
2211
|
-
const googleCount = await gforms.getExactResponseCount(event.gformsId);
|
|
2212
|
-
const localCount = event.registeredSlots;
|
|
2213
|
-
if (googleCount !== localCount) {
|
|
2214
|
-
console.warn(
|
|
2215
|
-
`[reconcile-slots] Drift on "${event.slug}": local=${localCount}, google=${googleCount}`
|
|
2216
|
-
);
|
|
2217
|
-
await slots.correctCount(event.slug, googleCount);
|
|
2218
|
-
corrected++;
|
|
2219
|
-
}
|
|
2220
|
-
} catch (err) {
|
|
2221
|
-
console.error(`[reconcile-slots] Error checking "${event.slug}":`, err);
|
|
2222
|
-
}
|
|
2223
|
-
}
|
|
2224
|
-
console.log(
|
|
2225
|
-
`[reconcile-slots] Checked ${eventsWithForms.length} events, corrected ${corrected}.`
|
|
2226
|
-
);
|
|
2227
|
-
} finally {
|
|
2228
|
-
await cache.del(LOCK_KEY);
|
|
2229
|
-
}
|
|
2230
|
-
}
|
|
2231
|
-
function parseStartTimestamp(dateTime, startTime) {
|
|
2232
|
-
if (!dateTime) return null;
|
|
2233
|
-
const combined = startTime ? `${dateTime} ${startTime}` : dateTime;
|
|
2234
|
-
const ms = Date.parse(combined);
|
|
2235
|
-
return Number.isNaN(ms) ? null : Math.floor(ms / 1e3);
|
|
2236
|
-
}
|
|
2237
|
-
async function reminderEmails(env) {
|
|
2238
|
-
if (!env.EMAIL_QUEUE) {
|
|
2239
|
-
console.warn(
|
|
2240
|
-
"[reminder-emails] EMAIL_QUEUE binding not configured, skipping."
|
|
2241
|
-
);
|
|
2242
|
-
return;
|
|
2243
|
-
}
|
|
2244
|
-
const hasSes = !!(env.SES_REGION && env.SES_ACCESS_KEY_ID && env.SES_SECRET_ACCESS_KEY);
|
|
2245
|
-
const hasResend = !!env.RESEND_API_KEY;
|
|
2246
|
-
if (!hasSes && !hasResend) {
|
|
2247
|
-
console.warn(
|
|
2248
|
-
"[reminder-emails] No email providers configured. Skipping reminders."
|
|
2249
|
-
);
|
|
2250
|
-
return;
|
|
2251
|
-
}
|
|
2252
|
-
const db = createDb(env.DB);
|
|
2253
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
2254
|
-
const candidates24h = await db.query.events.findMany({
|
|
2255
|
-
where: and(
|
|
2256
|
-
eq(events.status, "published"),
|
|
2257
|
-
eq(events.reminder24hSent, false)
|
|
2258
|
-
),
|
|
2259
|
-
columns: {
|
|
2260
|
-
id: true,
|
|
2261
|
-
dateTime: true,
|
|
2262
|
-
startTime: true
|
|
2263
|
-
}
|
|
2264
|
-
});
|
|
2265
|
-
for (const event of candidates24h) {
|
|
2266
|
-
const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
|
|
2267
|
-
if (!startsAt) continue;
|
|
2268
|
-
const hoursUntil = (startsAt - now) / 3600;
|
|
2269
|
-
if (hoursUntil <= 25 && hoursUntil >= 23) {
|
|
2270
|
-
await env.EMAIL_QUEUE.send({
|
|
2271
|
-
type: "send_reminder_email",
|
|
2272
|
-
payload: { eventId: event.id, hoursBeforeEvent: 24 }
|
|
2273
|
-
});
|
|
2274
|
-
}
|
|
2275
|
-
}
|
|
2276
|
-
const candidates1h = await db.query.events.findMany({
|
|
2277
|
-
where: and(
|
|
2278
|
-
eq(events.status, "published"),
|
|
2279
|
-
eq(events.reminder1hSent, false)
|
|
2280
|
-
),
|
|
2281
|
-
columns: {
|
|
2282
|
-
id: true,
|
|
2283
|
-
dateTime: true,
|
|
2284
|
-
startTime: true
|
|
2285
|
-
}
|
|
2286
|
-
});
|
|
2287
|
-
for (const event of candidates1h) {
|
|
2288
|
-
const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
|
|
2289
|
-
if (!startsAt) continue;
|
|
2290
|
-
const hoursUntil = (startsAt - now) / 3600;
|
|
2291
|
-
if (hoursUntil <= 1.5 && hoursUntil >= 0) {
|
|
2292
|
-
await env.EMAIL_QUEUE.send({
|
|
2293
|
-
type: "send_reminder_email",
|
|
2294
|
-
payload: { eventId: event.id, hoursBeforeEvent: 1 }
|
|
2295
|
-
});
|
|
2296
|
-
}
|
|
2297
|
-
}
|
|
2298
|
-
}
|
|
2299
|
-
var RENEWAL_WINDOW = 86400;
|
|
2300
|
-
async function renewWatches(env) {
|
|
2301
|
-
const db = createDb(env.DB);
|
|
2302
|
-
const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
|
|
2303
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
2304
|
-
const threshold = now + RENEWAL_WINDOW;
|
|
2305
|
-
const expiring = await db.query.events.findMany({
|
|
2306
|
-
where: and(
|
|
2307
|
-
eq(events.status, "published"),
|
|
2308
|
-
lte(events.watchExpiresAt, threshold)
|
|
2309
|
-
),
|
|
2310
|
-
columns: { id: true, slug: true, gformsId: true, watchId: true, watchExpiresAt: true }
|
|
2311
|
-
});
|
|
2312
|
-
const watchEvents = expiring.filter((e) => e.gformsId && e.watchId);
|
|
2313
|
-
let renewed = 0;
|
|
2314
|
-
for (const event of watchEvents) {
|
|
2315
|
-
try {
|
|
2316
|
-
const result = await gforms.renewWatch(event.gformsId, event.watchId);
|
|
2317
|
-
const newExpiry = Math.floor(new Date(result.expireTime).getTime() / 1e3);
|
|
2318
|
-
await db.update(events).set({ watchExpiresAt: newExpiry }).where(eq(events.id, event.id));
|
|
2319
|
-
renewed++;
|
|
2320
|
-
console.log(`[renew-watches] Renewed Watch for "${event.slug}", expires ${result.expireTime}`);
|
|
2321
|
-
} catch (err) {
|
|
2322
|
-
console.error(`[renew-watches] Failed to renew Watch for "${event.slug}":`, err);
|
|
2323
|
-
}
|
|
2324
|
-
}
|
|
2325
|
-
console.log(`[renew-watches] Renewed ${renewed}/${watchEvents.length} watches.`);
|
|
2326
|
-
}
|
|
2327
2448
|
|
|
2328
2449
|
// src/db/migrate.ts
|
|
2329
2450
|
var PATCH_STATEMENTS = [
|
|
@@ -2530,7 +2651,7 @@ function defaultGetRuntimeConfig(env) {
|
|
|
2530
2651
|
};
|
|
2531
2652
|
}
|
|
2532
2653
|
function injectConfig(html, config) {
|
|
2533
|
-
const configScript = `<script>window.__CONFIG__=${JSON.stringify(config)}
|
|
2654
|
+
const configScript = `<script>window.__CONFIG__=${JSON.stringify(config)};<\/script>`;
|
|
2534
2655
|
return html.replace("</head>", `${configScript}</head>`);
|
|
2535
2656
|
}
|
|
2536
2657
|
function createWorkerHandler(options) {
|
|
@@ -2652,10 +2773,10 @@ function getRuntimeConfig(env) {
|
|
|
2652
2773
|
};
|
|
2653
2774
|
}
|
|
2654
2775
|
function injectConfig2(html, config) {
|
|
2655
|
-
const configScript = `<script>window.__CONFIG__=${JSON.stringify(config)}
|
|
2776
|
+
const configScript = `<script>window.__CONFIG__=${JSON.stringify(config)};<\/script>`;
|
|
2656
2777
|
return html.replace("</head>", `${configScript}</head>`);
|
|
2657
2778
|
}
|
|
2658
2779
|
|
|
2659
2780
|
export { authAccount, authSession, authUser, authVerification, bookmarks, bookmarksRelations, createDb, createLeapify, createQueueHandler, createWorkerHandler, ensureDatabase, events, eventsRelations, faqs, getRuntimeConfig, injectConfig2 as injectConfig, organizations, organizationsRelations, siteConfig, themes, themesRelations, users };
|
|
2660
2781
|
//# sourceMappingURL=index.js.map
|
|
2661
|
-
//# sourceMappingURL=index.js.
|
|
2782
|
+
//# sourceMappingURL=index.js.map
|