@access-dlsu/leapify 0.260601.1 → 0.260602.1
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/{chunk-2JEY6TSO.js → chunk-WTA2QGY5.js} +4 -2
- package/dist/chunk-WTA2QGY5.js.map +1 -0
- package/dist/{chunk-X4OB4DZ3.cjs → chunk-ZV4TIJXI.cjs} +4 -2
- package/dist/chunk-ZV4TIJXI.cjs.map +1 -0
- package/dist/client/auth.d.ts +41 -41
- package/dist/client/index.cjs +7 -2
- package/dist/client/index.cjs.map +1 -1
- package/dist/client/index.d.ts +10 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +7 -2
- package/dist/client/index.js.map +1 -1
- package/dist/client/types.d.ts +4 -0
- package/dist/client/types.d.ts.map +1 -1
- package/dist/cron/reconcile-slots.d.ts.map +1 -1
- package/dist/db/schema/themes.d.ts +74 -0
- package/dist/db/schema/themes.d.ts.map +1 -1
- package/dist/index.cjs +251 -179
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +251 -179
- package/dist/index.js.map +1 -1
- package/dist/lib/middleware/turnstile-challenge.cjs +6 -6
- package/dist/lib/middleware/turnstile-challenge.d.ts.map +1 -1
- package/dist/lib/middleware/turnstile-challenge.js +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/themes.d.ts.map +1 -1
- package/dist/services/gforms.d.ts.map +1 -1
- package/dist/worker.js +249 -175
- package/dist/worker.js.map +1 -1
- package/package.json +2 -2
- package/dist/chunk-2JEY6TSO.js.map +0 -1
- package/dist/chunk-X4OB4DZ3.cjs.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var
|
|
3
|
+
var chunkZV4TIJXI_cjs = require('./chunk-ZV4TIJXI.cjs');
|
|
4
4
|
var chunkQ7SFCCGT_cjs = require('./chunk-Q7SFCCGT.cjs');
|
|
5
5
|
var hono = require('hono');
|
|
6
6
|
var cors = require('hono/cors');
|
|
@@ -22,8 +22,6 @@ var LeapifyError = class extends Error {
|
|
|
22
22
|
this.code = code;
|
|
23
23
|
this.name = "LeapifyError";
|
|
24
24
|
}
|
|
25
|
-
statusCode;
|
|
26
|
-
code;
|
|
27
25
|
};
|
|
28
26
|
var unauthorized = (message = "Unauthorized") => new LeapifyError(401, "UNAUTHORIZED", message);
|
|
29
27
|
var domainRestricted = () => new LeapifyError(
|
|
@@ -89,6 +87,10 @@ var themes = sqliteCore.sqliteTable("themes", {
|
|
|
89
87
|
name: sqliteCore.text("name").notNull().unique(),
|
|
90
88
|
path: sqliteCore.text("path").notNull().unique(),
|
|
91
89
|
// e.g. "/pirates-cove"
|
|
90
|
+
imageUrl: sqliteCore.text("image_url"),
|
|
91
|
+
descriptionEn: sqliteCore.text("description_en"),
|
|
92
|
+
descriptionFil: sqliteCore.text("description_fil"),
|
|
93
|
+
sortOrder: sqliteCore.integer("sort_order").notNull().default(0),
|
|
92
94
|
createdAt: sqliteCore.integer("created_at").notNull().$defaultFn(() => Math.floor(Date.now() / 1e3)),
|
|
93
95
|
updatedAt: sqliteCore.integer("updated_at").notNull().$defaultFn(() => Math.floor(Date.now() / 1e3))
|
|
94
96
|
});
|
|
@@ -750,7 +752,6 @@ var CacheService = class {
|
|
|
750
752
|
constructor(kv) {
|
|
751
753
|
this.kv = kv;
|
|
752
754
|
}
|
|
753
|
-
kv;
|
|
754
755
|
async get(key) {
|
|
755
756
|
return this.kv.get(key, "json");
|
|
756
757
|
}
|
|
@@ -796,8 +797,6 @@ var SlotsService = class {
|
|
|
796
797
|
this.db = db;
|
|
797
798
|
this.cache = cache;
|
|
798
799
|
}
|
|
799
|
-
db;
|
|
800
|
-
cache;
|
|
801
800
|
kvKey(slug) {
|
|
802
801
|
return `${SLOT_KV_PREFIX}${slug}`;
|
|
803
802
|
}
|
|
@@ -952,7 +951,10 @@ var GFormsService = class {
|
|
|
952
951
|
const response = await fetch(url.toString(), {
|
|
953
952
|
headers: { Authorization: `Bearer ${token}` }
|
|
954
953
|
});
|
|
955
|
-
if (!response.ok)
|
|
954
|
+
if (!response.ok) {
|
|
955
|
+
const err = await response.text();
|
|
956
|
+
throw new Error(`Forms API error: ${response.status} ${err}`);
|
|
957
|
+
}
|
|
956
958
|
const data = await response.json();
|
|
957
959
|
allResponses.push(...data.responses ?? []);
|
|
958
960
|
pageToken = data.nextPageToken;
|
|
@@ -1226,6 +1228,27 @@ classesRoute.get("/:slug/slots", eventsSlotsRateLimit, async (c) => {
|
|
|
1226
1228
|
c.header("Cache-Control", "public, max-age=5, stale-while-revalidate=5");
|
|
1227
1229
|
return c.json({ data: info });
|
|
1228
1230
|
});
|
|
1231
|
+
classesRoute.post("/:slug/reconcile", authMiddleware, adminMiddleware, async (c) => {
|
|
1232
|
+
const { slug } = c.req.param();
|
|
1233
|
+
const db = createDb(c.env.DB);
|
|
1234
|
+
const cache = new CacheService(c.env.KV);
|
|
1235
|
+
const gforms = new GFormsService(c.env.GFORMS_SERVICE_ACCOUNT_JSON);
|
|
1236
|
+
const slots = new SlotsService(db, cache);
|
|
1237
|
+
const event = await db.query.events.findFirst({
|
|
1238
|
+
where: drizzleOrm.eq(events.slug, slug),
|
|
1239
|
+
columns: { gformsId: true }
|
|
1240
|
+
});
|
|
1241
|
+
if (!event) throw notFound("Event");
|
|
1242
|
+
if (!event.gformsId) return c.json({ error: "No gformsId set for this event" }, 400);
|
|
1243
|
+
try {
|
|
1244
|
+
const googleCount = await gforms.getExactResponseCount(event.gformsId);
|
|
1245
|
+
await slots.correctCount(slug, googleCount);
|
|
1246
|
+
return c.json({ data: { registeredSlots: googleCount } });
|
|
1247
|
+
} catch (err) {
|
|
1248
|
+
const message = err?.message ?? "Failed to fetch from Google Forms API";
|
|
1249
|
+
return c.json({ error: { code: "GFORMS_API_ERROR", message } }, 502);
|
|
1250
|
+
}
|
|
1251
|
+
});
|
|
1229
1252
|
classesRoute.post(
|
|
1230
1253
|
"/",
|
|
1231
1254
|
authMiddleware,
|
|
@@ -1431,7 +1454,7 @@ faqsRoute.get("/", async (c) => {
|
|
|
1431
1454
|
const data = await cache.getOrSet(
|
|
1432
1455
|
FAQS_KV_KEY,
|
|
1433
1456
|
() => db.query.faqs.findMany({
|
|
1434
|
-
orderBy: (t, { asc }) => [
|
|
1457
|
+
orderBy: (t, { asc: asc2 }) => [asc2(t.sortOrder), asc2(t.createdAt)]
|
|
1435
1458
|
}),
|
|
1436
1459
|
FAQS_TTL
|
|
1437
1460
|
);
|
|
@@ -1533,6 +1556,196 @@ async function verifyGoogSignature(body, signature, secret) {
|
|
|
1533
1556
|
return false;
|
|
1534
1557
|
}
|
|
1535
1558
|
}
|
|
1559
|
+
var LOCK_KEY = "cron:reconcile-slots:lock";
|
|
1560
|
+
var LOCK_TTL = 300;
|
|
1561
|
+
async function reconcileSlots(env) {
|
|
1562
|
+
const db = createDb(env.DB);
|
|
1563
|
+
const cache = new CacheService(env.KV);
|
|
1564
|
+
const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
|
|
1565
|
+
const slots = new SlotsService(db, cache);
|
|
1566
|
+
const lock = await cache.get(LOCK_KEY);
|
|
1567
|
+
if (lock) {
|
|
1568
|
+
console.log("[reconcile-slots] Lock held, skipping.");
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
await cache.set(LOCK_KEY, "1", LOCK_TTL);
|
|
1572
|
+
try {
|
|
1573
|
+
const publishedEvents = await db.query.events.findMany({
|
|
1574
|
+
where: drizzleOrm.isNotNull(events.gformsId),
|
|
1575
|
+
columns: { id: true, slug: true, gformsId: true, registeredSlots: true }
|
|
1576
|
+
});
|
|
1577
|
+
const eventsWithForms = publishedEvents.filter((e) => e.gformsId);
|
|
1578
|
+
let corrected = 0;
|
|
1579
|
+
for (const event of eventsWithForms) {
|
|
1580
|
+
try {
|
|
1581
|
+
const googleCount = await gforms.getExactResponseCount(event.gformsId);
|
|
1582
|
+
const localCount = event.registeredSlots;
|
|
1583
|
+
if (googleCount !== localCount) {
|
|
1584
|
+
console.warn(
|
|
1585
|
+
`[reconcile-slots] Drift on "${event.slug}": local=${localCount}, google=${googleCount}`
|
|
1586
|
+
);
|
|
1587
|
+
await slots.correctCount(event.slug, googleCount);
|
|
1588
|
+
corrected++;
|
|
1589
|
+
}
|
|
1590
|
+
} catch (err) {
|
|
1591
|
+
console.error(`[reconcile-slots] Error checking "${event.slug}":`, err);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
console.log(
|
|
1595
|
+
`[reconcile-slots] Checked ${eventsWithForms.length} events, corrected ${corrected}.`
|
|
1596
|
+
);
|
|
1597
|
+
} finally {
|
|
1598
|
+
await cache.del(LOCK_KEY);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
// src/routes/internal/reconcile-slots.ts
|
|
1603
|
+
var reconcileSlotsRoute = new hono.Hono();
|
|
1604
|
+
reconcileSlotsRoute.post("/", internalMiddleware, async (c) => {
|
|
1605
|
+
await reconcileSlots(c.env);
|
|
1606
|
+
return c.json({ ok: true });
|
|
1607
|
+
});
|
|
1608
|
+
async function batchRelease(env) {
|
|
1609
|
+
const db = createDb(env.DB);
|
|
1610
|
+
const cache = new CacheService(env.KV);
|
|
1611
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1612
|
+
const toPublish = await db.query.events.findMany({
|
|
1613
|
+
where: drizzleOrm.and(drizzleOrm.eq(events.status, "queued"), drizzleOrm.lte(events.releaseAt, now)),
|
|
1614
|
+
columns: { id: true, slug: true }
|
|
1615
|
+
});
|
|
1616
|
+
if (toPublish.length === 0) return;
|
|
1617
|
+
const ids = toPublish.map((e) => e.id);
|
|
1618
|
+
await db.update(events).set({ status: "published", publishedAt: drizzleOrm.sql`(unixepoch())` }).where(
|
|
1619
|
+
// Drizzle doesn't have inArray for D1; use raw SQL for batch
|
|
1620
|
+
drizzleOrm.sql`${events.id} IN (${drizzleOrm.sql.join(
|
|
1621
|
+
ids.map((id) => drizzleOrm.sql`${id}`),
|
|
1622
|
+
drizzleOrm.sql`, `
|
|
1623
|
+
)})`
|
|
1624
|
+
);
|
|
1625
|
+
await cache.del("events:list");
|
|
1626
|
+
await cache.del("events:etag");
|
|
1627
|
+
console.log(
|
|
1628
|
+
`[batch-release] Published ${toPublish.length} events:`,
|
|
1629
|
+
toPublish.map((e) => e.slug).join(", ")
|
|
1630
|
+
);
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// src/routes/internal/batch-release.ts
|
|
1634
|
+
var batchReleaseRoute = new hono.Hono();
|
|
1635
|
+
batchReleaseRoute.post("/", internalMiddleware, async (c) => {
|
|
1636
|
+
await batchRelease(c.env);
|
|
1637
|
+
return c.json({ ok: true });
|
|
1638
|
+
});
|
|
1639
|
+
function parseStartTimestamp(dateTime, startTime) {
|
|
1640
|
+
if (!dateTime) return null;
|
|
1641
|
+
const combined = startTime ? `${dateTime} ${startTime}` : dateTime;
|
|
1642
|
+
const ms = Date.parse(combined);
|
|
1643
|
+
return Number.isNaN(ms) ? null : Math.floor(ms / 1e3);
|
|
1644
|
+
}
|
|
1645
|
+
async function reminderEmails(env) {
|
|
1646
|
+
if (!env.EMAIL_QUEUE) {
|
|
1647
|
+
console.warn(
|
|
1648
|
+
"[reminder-emails] EMAIL_QUEUE binding not configured, skipping."
|
|
1649
|
+
);
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
const hasSes = !!(env.SES_REGION && env.SES_ACCESS_KEY_ID && env.SES_SECRET_ACCESS_KEY);
|
|
1653
|
+
const hasResend = !!env.RESEND_API_KEY;
|
|
1654
|
+
if (!hasSes && !hasResend) {
|
|
1655
|
+
console.warn(
|
|
1656
|
+
"[reminder-emails] No email providers configured. Skipping reminders."
|
|
1657
|
+
);
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
const db = createDb(env.DB);
|
|
1661
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1662
|
+
const candidates24h = await db.query.events.findMany({
|
|
1663
|
+
where: drizzleOrm.and(
|
|
1664
|
+
drizzleOrm.eq(events.status, "published"),
|
|
1665
|
+
drizzleOrm.eq(events.reminder24hSent, false)
|
|
1666
|
+
),
|
|
1667
|
+
columns: {
|
|
1668
|
+
id: true,
|
|
1669
|
+
dateTime: true,
|
|
1670
|
+
startTime: true
|
|
1671
|
+
}
|
|
1672
|
+
});
|
|
1673
|
+
for (const event of candidates24h) {
|
|
1674
|
+
const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
|
|
1675
|
+
if (!startsAt) continue;
|
|
1676
|
+
const hoursUntil = (startsAt - now) / 3600;
|
|
1677
|
+
if (hoursUntil <= 25 && hoursUntil >= 23) {
|
|
1678
|
+
await env.EMAIL_QUEUE.send({
|
|
1679
|
+
type: "send_reminder_email",
|
|
1680
|
+
payload: { eventId: event.id, hoursBeforeEvent: 24 }
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
const candidates1h = await db.query.events.findMany({
|
|
1685
|
+
where: drizzleOrm.and(
|
|
1686
|
+
drizzleOrm.eq(events.status, "published"),
|
|
1687
|
+
drizzleOrm.eq(events.reminder1hSent, false)
|
|
1688
|
+
),
|
|
1689
|
+
columns: {
|
|
1690
|
+
id: true,
|
|
1691
|
+
dateTime: true,
|
|
1692
|
+
startTime: true
|
|
1693
|
+
}
|
|
1694
|
+
});
|
|
1695
|
+
for (const event of candidates1h) {
|
|
1696
|
+
const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
|
|
1697
|
+
if (!startsAt) continue;
|
|
1698
|
+
const hoursUntil = (startsAt - now) / 3600;
|
|
1699
|
+
if (hoursUntil <= 1.5 && hoursUntil >= 0) {
|
|
1700
|
+
await env.EMAIL_QUEUE.send({
|
|
1701
|
+
type: "send_reminder_email",
|
|
1702
|
+
payload: { eventId: event.id, hoursBeforeEvent: 1 }
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// src/routes/internal/reminder-emails.ts
|
|
1709
|
+
var reminderEmailsRoute = new hono.Hono();
|
|
1710
|
+
reminderEmailsRoute.post("/", internalMiddleware, async (c) => {
|
|
1711
|
+
await reminderEmails(c.env);
|
|
1712
|
+
return c.json({ ok: true });
|
|
1713
|
+
});
|
|
1714
|
+
var RENEWAL_WINDOW = 86400;
|
|
1715
|
+
async function renewWatches(env) {
|
|
1716
|
+
const db = createDb(env.DB);
|
|
1717
|
+
const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
|
|
1718
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1719
|
+
const threshold = now + RENEWAL_WINDOW;
|
|
1720
|
+
const expiring = await db.query.events.findMany({
|
|
1721
|
+
where: drizzleOrm.and(
|
|
1722
|
+
drizzleOrm.eq(events.status, "published"),
|
|
1723
|
+
drizzleOrm.lte(events.watchExpiresAt, threshold)
|
|
1724
|
+
),
|
|
1725
|
+
columns: { id: true, slug: true, gformsId: true, watchId: true, watchExpiresAt: true }
|
|
1726
|
+
});
|
|
1727
|
+
const watchEvents = expiring.filter((e) => e.gformsId && e.watchId);
|
|
1728
|
+
let renewed = 0;
|
|
1729
|
+
for (const event of watchEvents) {
|
|
1730
|
+
try {
|
|
1731
|
+
const result = await gforms.renewWatch(event.gformsId, event.watchId);
|
|
1732
|
+
const newExpiry = Math.floor(new Date(result.expireTime).getTime() / 1e3);
|
|
1733
|
+
await db.update(events).set({ watchExpiresAt: newExpiry }).where(drizzleOrm.eq(events.id, event.id));
|
|
1734
|
+
renewed++;
|
|
1735
|
+
console.log(`[renew-watches] Renewed Watch for "${event.slug}", expires ${result.expireTime}`);
|
|
1736
|
+
} catch (err) {
|
|
1737
|
+
console.error(`[renew-watches] Failed to renew Watch for "${event.slug}":`, err);
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
console.log(`[renew-watches] Renewed ${renewed}/${watchEvents.length} watches.`);
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// src/routes/internal/renew-watches.ts
|
|
1744
|
+
var renewWatchesRoute = new hono.Hono();
|
|
1745
|
+
renewWatchesRoute.post("/", internalMiddleware, async (c) => {
|
|
1746
|
+
await renewWatches(c.env);
|
|
1747
|
+
return c.json({ ok: true });
|
|
1748
|
+
});
|
|
1536
1749
|
var ALLOWED_MIME_TYPES = /* @__PURE__ */ new Set([
|
|
1537
1750
|
"image/jpeg",
|
|
1538
1751
|
"image/png",
|
|
@@ -1632,14 +1845,27 @@ function extensionFromMime(mime) {
|
|
|
1632
1845
|
};
|
|
1633
1846
|
return map[mime] ?? "bin";
|
|
1634
1847
|
}
|
|
1848
|
+
function generatePath(name) {
|
|
1849
|
+
return name.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1850
|
+
}
|
|
1635
1851
|
var createThemeSchema = zod.z.object({
|
|
1636
1852
|
name: zod.z.string().min(1),
|
|
1637
|
-
|
|
1853
|
+
imageUrl: zod.z.string().url().nullable().optional(),
|
|
1854
|
+
descriptionEn: zod.z.string().nullable().optional(),
|
|
1855
|
+
descriptionFil: zod.z.string().nullable().optional(),
|
|
1856
|
+
sortOrder: zod.z.number().int().default(0)
|
|
1857
|
+
});
|
|
1858
|
+
zod.z.object({
|
|
1859
|
+
name: zod.z.string().min(1).optional(),
|
|
1860
|
+
imageUrl: zod.z.string().url().nullable().optional(),
|
|
1861
|
+
descriptionEn: zod.z.string().nullable().optional(),
|
|
1862
|
+
descriptionFil: zod.z.string().nullable().optional(),
|
|
1863
|
+
sortOrder: zod.z.number().int().optional()
|
|
1638
1864
|
});
|
|
1639
1865
|
var themesRoute = new hono.Hono();
|
|
1640
1866
|
themesRoute.get("/", async (c) => {
|
|
1641
1867
|
const db = createDb(c.env.DB);
|
|
1642
|
-
const data = await db.select().from(themes);
|
|
1868
|
+
const data = await db.select().from(themes).orderBy(drizzleOrm.asc(themes.sortOrder), drizzleOrm.asc(themes.createdAt));
|
|
1643
1869
|
return c.json({ data });
|
|
1644
1870
|
});
|
|
1645
1871
|
themesRoute.post(
|
|
@@ -1650,8 +1876,9 @@ themesRoute.post(
|
|
|
1650
1876
|
async (c) => {
|
|
1651
1877
|
const body = c.req.valid("json");
|
|
1652
1878
|
const db = createDb(c.env.DB);
|
|
1879
|
+
const path = generatePath(body.name);
|
|
1653
1880
|
try {
|
|
1654
|
-
const [created] = await db.insert(themes).values(body).returning();
|
|
1881
|
+
const [created] = await db.insert(themes).values({ ...body, path }).returning();
|
|
1655
1882
|
return c.json({ data: created }, 201);
|
|
1656
1883
|
} catch (err) {
|
|
1657
1884
|
if (err.message && err.message.includes("UNIQUE constraint failed")) {
|
|
@@ -1669,8 +1896,12 @@ themesRoute.patch(
|
|
|
1669
1896
|
const { id } = c.req.param();
|
|
1670
1897
|
const body = await c.req.json();
|
|
1671
1898
|
const db = createDb(c.env.DB);
|
|
1899
|
+
const update = { ...body };
|
|
1900
|
+
if (body.name) {
|
|
1901
|
+
update.path = generatePath(body.name);
|
|
1902
|
+
}
|
|
1672
1903
|
try {
|
|
1673
|
-
const [updated] = await db.update(themes).set(
|
|
1904
|
+
const [updated] = await db.update(themes).set(update).where(drizzleOrm.eq(themes.id, id)).returning();
|
|
1674
1905
|
if (!updated) throw notFound("Theme");
|
|
1675
1906
|
return c.json({ data: updated });
|
|
1676
1907
|
} catch (err) {
|
|
@@ -1758,7 +1989,7 @@ function createApp(options = {}) {
|
|
|
1758
1989
|
});
|
|
1759
1990
|
}
|
|
1760
1991
|
app.use("*", createCorsMiddleware(options.allowedOrigins ?? ["*"]));
|
|
1761
|
-
app.use("*",
|
|
1992
|
+
app.use("*", chunkZV4TIJXI_cjs.createTurnstileMiddleware());
|
|
1762
1993
|
app.use("*", createRefererGuard(options.allowedOrigins ?? ["*"]));
|
|
1763
1994
|
app.on(["POST", "GET"], "/api/auth/*", (c) => {
|
|
1764
1995
|
const auth = createAuth(c.env);
|
|
@@ -1789,7 +2020,7 @@ function createApp(options = {}) {
|
|
|
1789
2020
|
}
|
|
1790
2021
|
return next();
|
|
1791
2022
|
});
|
|
1792
|
-
app.post(
|
|
2023
|
+
app.post(chunkZV4TIJXI_cjs.TURNSTILE_VERIFY_PATH, chunkZV4TIJXI_cjs.handleTurnstileVerify);
|
|
1793
2024
|
app.route("/health", healthRoute);
|
|
1794
2025
|
app.route("/api/config", siteConfigRoute);
|
|
1795
2026
|
app.route("/api/classes", classesRoute);
|
|
@@ -1799,6 +2030,10 @@ function createApp(options = {}) {
|
|
|
1799
2030
|
app.route("/api/faqs", faqsRoute);
|
|
1800
2031
|
app.route("/api/uploads", uploadsRoute);
|
|
1801
2032
|
app.route("/internal/gforms-webhook", gformsWebhookRoute);
|
|
2033
|
+
app.route("/internal/reconcile-slots", reconcileSlotsRoute);
|
|
2034
|
+
app.route("/internal/batch-release", batchReleaseRoute);
|
|
2035
|
+
app.route("/internal/reminder-emails", reminderEmailsRoute);
|
|
2036
|
+
app.route("/internal/renew-watches", renewWatchesRoute);
|
|
1802
2037
|
app.onError(errorHandler);
|
|
1803
2038
|
app.notFound(
|
|
1804
2039
|
(c) => c.json({ error: { code: "NOT_FOUND", message: "Route not found" } }, 404)
|
|
@@ -1904,7 +2139,6 @@ var SesError = class extends Error {
|
|
|
1904
2139
|
this.status = status;
|
|
1905
2140
|
this.name = "SesError";
|
|
1906
2141
|
}
|
|
1907
|
-
status;
|
|
1908
2142
|
/**
|
|
1909
2143
|
* True for errors that are permanent (not worth retrying via SES again).
|
|
1910
2144
|
* 400 BadRequest, 403 Forbidden, 404 NotFound → non-retryable.
|
|
@@ -2229,168 +2463,6 @@ async function processJob(job, services) {
|
|
|
2229
2463
|
}
|
|
2230
2464
|
}
|
|
2231
2465
|
}
|
|
2232
|
-
async function batchRelease(env) {
|
|
2233
|
-
const db = createDb(env.DB);
|
|
2234
|
-
const cache = new CacheService(env.KV);
|
|
2235
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
2236
|
-
const toPublish = await db.query.events.findMany({
|
|
2237
|
-
where: drizzleOrm.and(drizzleOrm.eq(events.status, "queued"), drizzleOrm.lte(events.releaseAt, now)),
|
|
2238
|
-
columns: { id: true, slug: true }
|
|
2239
|
-
});
|
|
2240
|
-
if (toPublish.length === 0) return;
|
|
2241
|
-
const ids = toPublish.map((e) => e.id);
|
|
2242
|
-
await db.update(events).set({ status: "published", publishedAt: drizzleOrm.sql`(unixepoch())` }).where(
|
|
2243
|
-
// Drizzle doesn't have inArray for D1; use raw SQL for batch
|
|
2244
|
-
drizzleOrm.sql`${events.id} IN (${drizzleOrm.sql.join(
|
|
2245
|
-
ids.map((id) => drizzleOrm.sql`${id}`),
|
|
2246
|
-
drizzleOrm.sql`, `
|
|
2247
|
-
)})`
|
|
2248
|
-
);
|
|
2249
|
-
await cache.del("events:list");
|
|
2250
|
-
await cache.del("events:etag");
|
|
2251
|
-
console.log(
|
|
2252
|
-
`[batch-release] Published ${toPublish.length} events:`,
|
|
2253
|
-
toPublish.map((e) => e.slug).join(", ")
|
|
2254
|
-
);
|
|
2255
|
-
}
|
|
2256
|
-
var LOCK_KEY = "cron:reconcile-slots:lock";
|
|
2257
|
-
var LOCK_TTL = 300;
|
|
2258
|
-
async function reconcileSlots(env) {
|
|
2259
|
-
const db = createDb(env.DB);
|
|
2260
|
-
const cache = new CacheService(env.KV);
|
|
2261
|
-
const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
|
|
2262
|
-
const slots = new SlotsService(db, cache);
|
|
2263
|
-
const lock = await cache.get(LOCK_KEY);
|
|
2264
|
-
if (lock) {
|
|
2265
|
-
console.log("[reconcile-slots] Lock held, skipping.");
|
|
2266
|
-
return;
|
|
2267
|
-
}
|
|
2268
|
-
await cache.set(LOCK_KEY, "1", LOCK_TTL);
|
|
2269
|
-
try {
|
|
2270
|
-
const publishedEvents = await db.query.events.findMany({
|
|
2271
|
-
where: drizzleOrm.eq(events.status, "published"),
|
|
2272
|
-
columns: { id: true, slug: true, gformsId: true, registeredSlots: true }
|
|
2273
|
-
});
|
|
2274
|
-
const eventsWithForms = publishedEvents.filter((e) => e.gformsId);
|
|
2275
|
-
let corrected = 0;
|
|
2276
|
-
for (const event of eventsWithForms) {
|
|
2277
|
-
try {
|
|
2278
|
-
const googleCount = await gforms.getExactResponseCount(event.gformsId);
|
|
2279
|
-
const localCount = event.registeredSlots;
|
|
2280
|
-
if (googleCount !== localCount) {
|
|
2281
|
-
console.warn(
|
|
2282
|
-
`[reconcile-slots] Drift on "${event.slug}": local=${localCount}, google=${googleCount}`
|
|
2283
|
-
);
|
|
2284
|
-
await slots.correctCount(event.slug, googleCount);
|
|
2285
|
-
corrected++;
|
|
2286
|
-
}
|
|
2287
|
-
} catch (err) {
|
|
2288
|
-
console.error(`[reconcile-slots] Error checking "${event.slug}":`, err);
|
|
2289
|
-
}
|
|
2290
|
-
}
|
|
2291
|
-
console.log(
|
|
2292
|
-
`[reconcile-slots] Checked ${eventsWithForms.length} events, corrected ${corrected}.`
|
|
2293
|
-
);
|
|
2294
|
-
} finally {
|
|
2295
|
-
await cache.del(LOCK_KEY);
|
|
2296
|
-
}
|
|
2297
|
-
}
|
|
2298
|
-
function parseStartTimestamp(dateTime, startTime) {
|
|
2299
|
-
if (!dateTime) return null;
|
|
2300
|
-
const combined = startTime ? `${dateTime} ${startTime}` : dateTime;
|
|
2301
|
-
const ms = Date.parse(combined);
|
|
2302
|
-
return Number.isNaN(ms) ? null : Math.floor(ms / 1e3);
|
|
2303
|
-
}
|
|
2304
|
-
async function reminderEmails(env) {
|
|
2305
|
-
if (!env.EMAIL_QUEUE) {
|
|
2306
|
-
console.warn(
|
|
2307
|
-
"[reminder-emails] EMAIL_QUEUE binding not configured, skipping."
|
|
2308
|
-
);
|
|
2309
|
-
return;
|
|
2310
|
-
}
|
|
2311
|
-
const hasSes = !!(env.SES_REGION && env.SES_ACCESS_KEY_ID && env.SES_SECRET_ACCESS_KEY);
|
|
2312
|
-
const hasResend = !!env.RESEND_API_KEY;
|
|
2313
|
-
if (!hasSes && !hasResend) {
|
|
2314
|
-
console.warn(
|
|
2315
|
-
"[reminder-emails] No email providers configured. Skipping reminders."
|
|
2316
|
-
);
|
|
2317
|
-
return;
|
|
2318
|
-
}
|
|
2319
|
-
const db = createDb(env.DB);
|
|
2320
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
2321
|
-
const candidates24h = await db.query.events.findMany({
|
|
2322
|
-
where: drizzleOrm.and(
|
|
2323
|
-
drizzleOrm.eq(events.status, "published"),
|
|
2324
|
-
drizzleOrm.eq(events.reminder24hSent, false)
|
|
2325
|
-
),
|
|
2326
|
-
columns: {
|
|
2327
|
-
id: true,
|
|
2328
|
-
dateTime: true,
|
|
2329
|
-
startTime: true
|
|
2330
|
-
}
|
|
2331
|
-
});
|
|
2332
|
-
for (const event of candidates24h) {
|
|
2333
|
-
const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
|
|
2334
|
-
if (!startsAt) continue;
|
|
2335
|
-
const hoursUntil = (startsAt - now) / 3600;
|
|
2336
|
-
if (hoursUntil <= 25 && hoursUntil >= 23) {
|
|
2337
|
-
await env.EMAIL_QUEUE.send({
|
|
2338
|
-
type: "send_reminder_email",
|
|
2339
|
-
payload: { eventId: event.id, hoursBeforeEvent: 24 }
|
|
2340
|
-
});
|
|
2341
|
-
}
|
|
2342
|
-
}
|
|
2343
|
-
const candidates1h = await db.query.events.findMany({
|
|
2344
|
-
where: drizzleOrm.and(
|
|
2345
|
-
drizzleOrm.eq(events.status, "published"),
|
|
2346
|
-
drizzleOrm.eq(events.reminder1hSent, false)
|
|
2347
|
-
),
|
|
2348
|
-
columns: {
|
|
2349
|
-
id: true,
|
|
2350
|
-
dateTime: true,
|
|
2351
|
-
startTime: true
|
|
2352
|
-
}
|
|
2353
|
-
});
|
|
2354
|
-
for (const event of candidates1h) {
|
|
2355
|
-
const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
|
|
2356
|
-
if (!startsAt) continue;
|
|
2357
|
-
const hoursUntil = (startsAt - now) / 3600;
|
|
2358
|
-
if (hoursUntil <= 1.5 && hoursUntil >= 0) {
|
|
2359
|
-
await env.EMAIL_QUEUE.send({
|
|
2360
|
-
type: "send_reminder_email",
|
|
2361
|
-
payload: { eventId: event.id, hoursBeforeEvent: 1 }
|
|
2362
|
-
});
|
|
2363
|
-
}
|
|
2364
|
-
}
|
|
2365
|
-
}
|
|
2366
|
-
var RENEWAL_WINDOW = 86400;
|
|
2367
|
-
async function renewWatches(env) {
|
|
2368
|
-
const db = createDb(env.DB);
|
|
2369
|
-
const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
|
|
2370
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
2371
|
-
const threshold = now + RENEWAL_WINDOW;
|
|
2372
|
-
const expiring = await db.query.events.findMany({
|
|
2373
|
-
where: drizzleOrm.and(
|
|
2374
|
-
drizzleOrm.eq(events.status, "published"),
|
|
2375
|
-
drizzleOrm.lte(events.watchExpiresAt, threshold)
|
|
2376
|
-
),
|
|
2377
|
-
columns: { id: true, slug: true, gformsId: true, watchId: true, watchExpiresAt: true }
|
|
2378
|
-
});
|
|
2379
|
-
const watchEvents = expiring.filter((e) => e.gformsId && e.watchId);
|
|
2380
|
-
let renewed = 0;
|
|
2381
|
-
for (const event of watchEvents) {
|
|
2382
|
-
try {
|
|
2383
|
-
const result = await gforms.renewWatch(event.gformsId, event.watchId);
|
|
2384
|
-
const newExpiry = Math.floor(new Date(result.expireTime).getTime() / 1e3);
|
|
2385
|
-
await db.update(events).set({ watchExpiresAt: newExpiry }).where(drizzleOrm.eq(events.id, event.id));
|
|
2386
|
-
renewed++;
|
|
2387
|
-
console.log(`[renew-watches] Renewed Watch for "${event.slug}", expires ${result.expireTime}`);
|
|
2388
|
-
} catch (err) {
|
|
2389
|
-
console.error(`[renew-watches] Failed to renew Watch for "${event.slug}":`, err);
|
|
2390
|
-
}
|
|
2391
|
-
}
|
|
2392
|
-
console.log(`[renew-watches] Renewed ${renewed}/${watchEvents.length} watches.`);
|
|
2393
|
-
}
|
|
2394
2466
|
|
|
2395
2467
|
// src/db/migrate.ts
|
|
2396
2468
|
var PATCH_STATEMENTS = [
|
|
@@ -2597,7 +2669,7 @@ function defaultGetRuntimeConfig(env) {
|
|
|
2597
2669
|
};
|
|
2598
2670
|
}
|
|
2599
2671
|
function injectConfig(html, config) {
|
|
2600
|
-
const configScript = `<script>window.__CONFIG__=${JSON.stringify(config)}
|
|
2672
|
+
const configScript = `<script>window.__CONFIG__=${JSON.stringify(config)};<\/script>`;
|
|
2601
2673
|
return html.replace("</head>", `${configScript}</head>`);
|
|
2602
2674
|
}
|
|
2603
2675
|
function createWorkerHandler(options) {
|
|
@@ -2719,7 +2791,7 @@ function getRuntimeConfig(env) {
|
|
|
2719
2791
|
};
|
|
2720
2792
|
}
|
|
2721
2793
|
function injectConfig2(html, config) {
|
|
2722
|
-
const configScript = `<script>window.__CONFIG__=${JSON.stringify(config)}
|
|
2794
|
+
const configScript = `<script>window.__CONFIG__=${JSON.stringify(config)};<\/script>`;
|
|
2723
2795
|
return html.replace("</head>", `${configScript}</head>`);
|
|
2724
2796
|
}
|
|
2725
2797
|
|