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