@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.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
- // src/auth/auth.ts
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
- const hasGForms = Boolean(env.GFORMS_SERVICE_ACCOUNT_JSON);
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) 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
+ }
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: 600
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 = [