@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/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
- // src/auth/auth.ts
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
- const hasGForms = Boolean(env.GFORMS_SERVICE_ACCOUNT_JSON);
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) throw new Error(`Forms API error: ${response.status}`);
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: 600
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)};</script>`;
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)};</script>`;
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.mapap
2782
+ //# sourceMappingURL=index.js.map