@access-dlsu/leapify 0.260601.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/index.cjs CHANGED
@@ -952,7 +952,10 @@ var GFormsService = class {
952
952
  const response = await fetch(url.toString(), {
953
953
  headers: { Authorization: `Bearer ${token}` }
954
954
  });
955
- if (!response.ok) throw new Error(`Forms API error: ${response.status}`);
955
+ if (!response.ok) {
956
+ const err = await response.text();
957
+ throw new Error(`Forms API error: ${response.status} ${err}`);
958
+ }
956
959
  const data = await response.json();
957
960
  allResponses.push(...data.responses ?? []);
958
961
  pageToken = data.nextPageToken;
@@ -1226,6 +1229,27 @@ classesRoute.get("/:slug/slots", eventsSlotsRateLimit, async (c) => {
1226
1229
  c.header("Cache-Control", "public, max-age=5, stale-while-revalidate=5");
1227
1230
  return c.json({ data: info });
1228
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
+ });
1229
1253
  classesRoute.post(
1230
1254
  "/",
1231
1255
  authMiddleware,
@@ -1533,6 +1557,196 @@ async function verifyGoogSignature(body, signature, secret) {
1533
1557
  return false;
1534
1558
  }
1535
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
+ });
1536
1750
  var ALLOWED_MIME_TYPES = /* @__PURE__ */ new Set([
1537
1751
  "image/jpeg",
1538
1752
  "image/png",
@@ -1799,6 +2013,10 @@ function createApp(options = {}) {
1799
2013
  app.route("/api/faqs", faqsRoute);
1800
2014
  app.route("/api/uploads", uploadsRoute);
1801
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);
1802
2020
  app.onError(errorHandler);
1803
2021
  app.notFound(
1804
2022
  (c) => c.json({ error: { code: "NOT_FOUND", message: "Route not found" } }, 404)
@@ -2229,168 +2447,6 @@ async function processJob(job, services) {
2229
2447
  }
2230
2448
  }
2231
2449
  }
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
2450
 
2395
2451
  // src/db/migrate.ts
2396
2452
  var PATCH_STATEMENTS = [
@@ -2597,7 +2653,7 @@ function defaultGetRuntimeConfig(env) {
2597
2653
  };
2598
2654
  }
2599
2655
  function injectConfig(html, config) {
2600
- const configScript = `<script>window.__CONFIG__=${JSON.stringify(config)};</script>`;
2656
+ const configScript = `<script>window.__CONFIG__=${JSON.stringify(config)};<\/script>`;
2601
2657
  return html.replace("</head>", `${configScript}</head>`);
2602
2658
  }
2603
2659
  function createWorkerHandler(options) {
@@ -2719,7 +2775,7 @@ function getRuntimeConfig(env) {
2719
2775
  };
2720
2776
  }
2721
2777
  function injectConfig2(html, config) {
2722
- const configScript = `<script>window.__CONFIG__=${JSON.stringify(config)};</script>`;
2778
+ const configScript = `<script>window.__CONFIG__=${JSON.stringify(config)};<\/script>`;
2723
2779
  return html.replace("</head>", `${configScript}</head>`);
2724
2780
  }
2725
2781