@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.
@@ -0,0 +1,4 @@
1
+ import { Hono } from "hono";
2
+ import type { LeapifyEnv } from "../../types";
3
+ export declare const batchReleaseRoute: Hono<LeapifyEnv, import("hono/types").BlankSchema, "/">;
4
+ //# sourceMappingURL=batch-release.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"batch-release.d.ts","sourceRoot":"","sources":["../../../src/routes/internal/batch-release.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAI9C,eAAO,MAAM,iBAAiB,yDAAyB,CAAC"}
@@ -0,0 +1,4 @@
1
+ import { Hono } from "hono";
2
+ import type { LeapifyEnv } from "../../types";
3
+ export declare const reconcileSlotsRoute: Hono<LeapifyEnv, import("hono/types").BlankSchema, "/">;
4
+ //# sourceMappingURL=reconcile-slots.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reconcile-slots.d.ts","sourceRoot":"","sources":["../../../src/routes/internal/reconcile-slots.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAI9C,eAAO,MAAM,mBAAmB,yDAAyB,CAAC"}
@@ -0,0 +1,4 @@
1
+ import { Hono } from "hono";
2
+ import type { LeapifyEnv } from "../../types";
3
+ export declare const reminderEmailsRoute: Hono<LeapifyEnv, import("hono/types").BlankSchema, "/">;
4
+ //# sourceMappingURL=reminder-emails.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reminder-emails.d.ts","sourceRoot":"","sources":["../../../src/routes/internal/reminder-emails.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAI9C,eAAO,MAAM,mBAAmB,yDAAyB,CAAC"}
@@ -0,0 +1,4 @@
1
+ import { Hono } from "hono";
2
+ import type { LeapifyEnv } from "../../types";
3
+ export declare const renewWatchesRoute: Hono<LeapifyEnv, import("hono/types").BlankSchema, "/">;
4
+ //# sourceMappingURL=renew-watches.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"renew-watches.d.ts","sourceRoot":"","sources":["../../../src/routes/internal/renew-watches.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAI9C,eAAO,MAAM,iBAAiB,yDAAyB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"gforms.d.ts","sourceRoot":"","sources":["../../src/services/gforms.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAiBH,UAAU,YAAY;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAOD,qBAAa,aAAa;IACxB,OAAO,CAAC,WAAW,CAAuB;IAC1C,OAAO,CAAC,cAAc,CAAK;IAC3B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA4B;gBAE5C,kBAAkB,EAAE,MAAM;YAQxB,cAAc;YAWd,wBAAwB;YA6BxB,SAAS;IA6CvB;;;OAGG;IACG,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAIvD;;;OAGG;IACG,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;IA0B9D;;;OAGG;IACG,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAO5D;;;OAGG;IACG,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAO5D;;;;;;;OAOG;IACG,WAAW,CACf,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;IAiCnD;;OAEG;IACG,UAAU,CACd,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,GACd,OAAO,CAAC;QAAE,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;CAuBnC"}
1
+ {"version":3,"file":"gforms.d.ts","sourceRoot":"","sources":["../../src/services/gforms.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAiBH,UAAU,YAAY;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAOD,qBAAa,aAAa;IACxB,OAAO,CAAC,WAAW,CAAuB;IAC1C,OAAO,CAAC,cAAc,CAAK;IAC3B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA4B;gBAE5C,kBAAkB,EAAE,MAAM;YAQxB,cAAc;YAWd,wBAAwB;YA6BxB,SAAS;IA6CvB;;;OAGG;IACG,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAIvD;;;OAGG;IACG,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;IA6B9D;;;OAGG;IACG,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAO5D;;;OAGG;IACG,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAO5D;;;;;;;OAOG;IACG,WAAW,CACf,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;IAiCnD;;OAEG;IACG,UAAU,CACd,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,GACd,OAAO,CAAC;QAAE,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;CAuBnC"}
package/dist/worker.js CHANGED
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
2
2
  import { cors } from 'hono/cors';
3
3
  import { drizzle } from 'drizzle-orm/d1';
4
4
  import { sqliteTable, integer, text, index, uniqueIndex } from 'drizzle-orm/sqlite-core';
5
- import { sql, relations, eq, and, count, lte } from 'drizzle-orm';
5
+ import { sql, relations, eq, and, count, isNotNull, lte } from 'drizzle-orm';
6
6
  import { createMiddleware } from 'hono/factory';
7
7
  import { betterAuth } from 'better-auth';
8
8
  import { drizzleAdapter } from 'better-auth/adapters/drizzle';
@@ -1108,7 +1108,10 @@ var GFormsService = class {
1108
1108
  const response = await fetch(url.toString(), {
1109
1109
  headers: { Authorization: `Bearer ${token}` }
1110
1110
  });
1111
- if (!response.ok) throw new Error(`Forms API error: ${response.status}`);
1111
+ if (!response.ok) {
1112
+ const err = await response.text();
1113
+ throw new Error(`Forms API error: ${response.status} ${err}`);
1114
+ }
1112
1115
  const data = await response.json();
1113
1116
  allResponses.push(...data.responses ?? []);
1114
1117
  pageToken = data.nextPageToken;
@@ -1382,6 +1385,27 @@ classesRoute.get("/:slug/slots", eventsSlotsRateLimit, async (c) => {
1382
1385
  c.header("Cache-Control", "public, max-age=5, stale-while-revalidate=5");
1383
1386
  return c.json({ data: info });
1384
1387
  });
1388
+ classesRoute.post("/:slug/reconcile", authMiddleware, adminMiddleware, async (c) => {
1389
+ const { slug } = c.req.param();
1390
+ const db = createDb(c.env.DB);
1391
+ const cache = new CacheService(c.env.KV);
1392
+ const gforms = new GFormsService(c.env.GFORMS_SERVICE_ACCOUNT_JSON);
1393
+ const slots = new SlotsService(db, cache);
1394
+ const event = await db.query.events.findFirst({
1395
+ where: eq(events.slug, slug),
1396
+ columns: { gformsId: true }
1397
+ });
1398
+ if (!event) throw notFound("Event");
1399
+ if (!event.gformsId) return c.json({ error: "No gformsId set for this event" }, 400);
1400
+ try {
1401
+ const googleCount = await gforms.getExactResponseCount(event.gformsId);
1402
+ await slots.correctCount(slug, googleCount);
1403
+ return c.json({ data: { registeredSlots: googleCount } });
1404
+ } catch (err) {
1405
+ const message = err?.message ?? "Failed to fetch from Google Forms API";
1406
+ return c.json({ error: { code: "GFORMS_API_ERROR", message } }, 502);
1407
+ }
1408
+ });
1385
1409
  classesRoute.post(
1386
1410
  "/",
1387
1411
  authMiddleware,
@@ -1689,6 +1713,196 @@ async function verifyGoogSignature(body, signature, secret) {
1689
1713
  return false;
1690
1714
  }
1691
1715
  }
1716
+ var LOCK_KEY = "cron:reconcile-slots:lock";
1717
+ var LOCK_TTL = 300;
1718
+ async function reconcileSlots(env) {
1719
+ const db = createDb(env.DB);
1720
+ const cache = new CacheService(env.KV);
1721
+ const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
1722
+ const slots = new SlotsService(db, cache);
1723
+ const lock = await cache.get(LOCK_KEY);
1724
+ if (lock) {
1725
+ console.log("[reconcile-slots] Lock held, skipping.");
1726
+ return;
1727
+ }
1728
+ await cache.set(LOCK_KEY, "1", LOCK_TTL);
1729
+ try {
1730
+ const publishedEvents = await db.query.events.findMany({
1731
+ where: isNotNull(events.gformsId),
1732
+ columns: { id: true, slug: true, gformsId: true, registeredSlots: true }
1733
+ });
1734
+ const eventsWithForms = publishedEvents.filter((e) => e.gformsId);
1735
+ let corrected = 0;
1736
+ for (const event of eventsWithForms) {
1737
+ try {
1738
+ const googleCount = await gforms.getExactResponseCount(event.gformsId);
1739
+ const localCount = event.registeredSlots;
1740
+ if (googleCount !== localCount) {
1741
+ console.warn(
1742
+ `[reconcile-slots] Drift on "${event.slug}": local=${localCount}, google=${googleCount}`
1743
+ );
1744
+ await slots.correctCount(event.slug, googleCount);
1745
+ corrected++;
1746
+ }
1747
+ } catch (err) {
1748
+ console.error(`[reconcile-slots] Error checking "${event.slug}":`, err);
1749
+ }
1750
+ }
1751
+ console.log(
1752
+ `[reconcile-slots] Checked ${eventsWithForms.length} events, corrected ${corrected}.`
1753
+ );
1754
+ } finally {
1755
+ await cache.del(LOCK_KEY);
1756
+ }
1757
+ }
1758
+
1759
+ // src/routes/internal/reconcile-slots.ts
1760
+ var reconcileSlotsRoute = new Hono();
1761
+ reconcileSlotsRoute.post("/", internalMiddleware, async (c) => {
1762
+ await reconcileSlots(c.env);
1763
+ return c.json({ ok: true });
1764
+ });
1765
+ async function batchRelease(env) {
1766
+ const db = createDb(env.DB);
1767
+ const cache = new CacheService(env.KV);
1768
+ const now = Math.floor(Date.now() / 1e3);
1769
+ const toPublish = await db.query.events.findMany({
1770
+ where: and(eq(events.status, "queued"), lte(events.releaseAt, now)),
1771
+ columns: { id: true, slug: true }
1772
+ });
1773
+ if (toPublish.length === 0) return;
1774
+ const ids = toPublish.map((e) => e.id);
1775
+ await db.update(events).set({ status: "published", publishedAt: sql`(unixepoch())` }).where(
1776
+ // Drizzle doesn't have inArray for D1; use raw SQL for batch
1777
+ sql`${events.id} IN (${sql.join(
1778
+ ids.map((id) => sql`${id}`),
1779
+ sql`, `
1780
+ )})`
1781
+ );
1782
+ await cache.del("events:list");
1783
+ await cache.del("events:etag");
1784
+ console.log(
1785
+ `[batch-release] Published ${toPublish.length} events:`,
1786
+ toPublish.map((e) => e.slug).join(", ")
1787
+ );
1788
+ }
1789
+
1790
+ // src/routes/internal/batch-release.ts
1791
+ var batchReleaseRoute = new Hono();
1792
+ batchReleaseRoute.post("/", internalMiddleware, async (c) => {
1793
+ await batchRelease(c.env);
1794
+ return c.json({ ok: true });
1795
+ });
1796
+ function parseStartTimestamp(dateTime, startTime) {
1797
+ if (!dateTime) return null;
1798
+ const combined = startTime ? `${dateTime} ${startTime}` : dateTime;
1799
+ const ms = Date.parse(combined);
1800
+ return Number.isNaN(ms) ? null : Math.floor(ms / 1e3);
1801
+ }
1802
+ async function reminderEmails(env) {
1803
+ if (!env.EMAIL_QUEUE) {
1804
+ console.warn(
1805
+ "[reminder-emails] EMAIL_QUEUE binding not configured, skipping."
1806
+ );
1807
+ return;
1808
+ }
1809
+ const hasSes = !!(env.SES_REGION && env.SES_ACCESS_KEY_ID && env.SES_SECRET_ACCESS_KEY);
1810
+ const hasResend = !!env.RESEND_API_KEY;
1811
+ if (!hasSes && !hasResend) {
1812
+ console.warn(
1813
+ "[reminder-emails] No email providers configured. Skipping reminders."
1814
+ );
1815
+ return;
1816
+ }
1817
+ const db = createDb(env.DB);
1818
+ const now = Math.floor(Date.now() / 1e3);
1819
+ const candidates24h = await db.query.events.findMany({
1820
+ where: and(
1821
+ eq(events.status, "published"),
1822
+ eq(events.reminder24hSent, false)
1823
+ ),
1824
+ columns: {
1825
+ id: true,
1826
+ dateTime: true,
1827
+ startTime: true
1828
+ }
1829
+ });
1830
+ for (const event of candidates24h) {
1831
+ const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
1832
+ if (!startsAt) continue;
1833
+ const hoursUntil = (startsAt - now) / 3600;
1834
+ if (hoursUntil <= 25 && hoursUntil >= 23) {
1835
+ await env.EMAIL_QUEUE.send({
1836
+ type: "send_reminder_email",
1837
+ payload: { eventId: event.id, hoursBeforeEvent: 24 }
1838
+ });
1839
+ }
1840
+ }
1841
+ const candidates1h = await db.query.events.findMany({
1842
+ where: and(
1843
+ eq(events.status, "published"),
1844
+ eq(events.reminder1hSent, false)
1845
+ ),
1846
+ columns: {
1847
+ id: true,
1848
+ dateTime: true,
1849
+ startTime: true
1850
+ }
1851
+ });
1852
+ for (const event of candidates1h) {
1853
+ const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
1854
+ if (!startsAt) continue;
1855
+ const hoursUntil = (startsAt - now) / 3600;
1856
+ if (hoursUntil <= 1.5 && hoursUntil >= 0) {
1857
+ await env.EMAIL_QUEUE.send({
1858
+ type: "send_reminder_email",
1859
+ payload: { eventId: event.id, hoursBeforeEvent: 1 }
1860
+ });
1861
+ }
1862
+ }
1863
+ }
1864
+
1865
+ // src/routes/internal/reminder-emails.ts
1866
+ var reminderEmailsRoute = new Hono();
1867
+ reminderEmailsRoute.post("/", internalMiddleware, async (c) => {
1868
+ await reminderEmails(c.env);
1869
+ return c.json({ ok: true });
1870
+ });
1871
+ var RENEWAL_WINDOW = 86400;
1872
+ async function renewWatches(env) {
1873
+ const db = createDb(env.DB);
1874
+ const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
1875
+ const now = Math.floor(Date.now() / 1e3);
1876
+ const threshold = now + RENEWAL_WINDOW;
1877
+ const expiring = await db.query.events.findMany({
1878
+ where: and(
1879
+ eq(events.status, "published"),
1880
+ lte(events.watchExpiresAt, threshold)
1881
+ ),
1882
+ columns: { id: true, slug: true, gformsId: true, watchId: true, watchExpiresAt: true }
1883
+ });
1884
+ const watchEvents = expiring.filter((e) => e.gformsId && e.watchId);
1885
+ let renewed = 0;
1886
+ for (const event of watchEvents) {
1887
+ try {
1888
+ const result = await gforms.renewWatch(event.gformsId, event.watchId);
1889
+ const newExpiry = Math.floor(new Date(result.expireTime).getTime() / 1e3);
1890
+ await db.update(events).set({ watchExpiresAt: newExpiry }).where(eq(events.id, event.id));
1891
+ renewed++;
1892
+ console.log(`[renew-watches] Renewed Watch for "${event.slug}", expires ${result.expireTime}`);
1893
+ } catch (err) {
1894
+ console.error(`[renew-watches] Failed to renew Watch for "${event.slug}":`, err);
1895
+ }
1896
+ }
1897
+ console.log(`[renew-watches] Renewed ${renewed}/${watchEvents.length} watches.`);
1898
+ }
1899
+
1900
+ // src/routes/internal/renew-watches.ts
1901
+ var renewWatchesRoute = new Hono();
1902
+ renewWatchesRoute.post("/", internalMiddleware, async (c) => {
1903
+ await renewWatches(c.env);
1904
+ return c.json({ ok: true });
1905
+ });
1692
1906
  var ALLOWED_MIME_TYPES = /* @__PURE__ */ new Set([
1693
1907
  "image/jpeg",
1694
1908
  "image/png",
@@ -1955,6 +2169,10 @@ function createApp(options = {}) {
1955
2169
  app2.route("/api/faqs", faqsRoute);
1956
2170
  app2.route("/api/uploads", uploadsRoute);
1957
2171
  app2.route("/internal/gforms-webhook", gformsWebhookRoute);
2172
+ app2.route("/internal/reconcile-slots", reconcileSlotsRoute);
2173
+ app2.route("/internal/batch-release", batchReleaseRoute);
2174
+ app2.route("/internal/reminder-emails", reminderEmailsRoute);
2175
+ app2.route("/internal/renew-watches", renewWatchesRoute);
1958
2176
  app2.onError(errorHandler);
1959
2177
  app2.notFound(
1960
2178
  (c) => c.json({ error: { code: "NOT_FOUND", message: "Route not found" } }, 404)
@@ -2385,168 +2603,6 @@ async function processJob(job, services) {
2385
2603
  }
2386
2604
  }
2387
2605
  }
2388
- async function batchRelease(env) {
2389
- const db = createDb(env.DB);
2390
- const cache = new CacheService(env.KV);
2391
- const now = Math.floor(Date.now() / 1e3);
2392
- const toPublish = await db.query.events.findMany({
2393
- where: and(eq(events.status, "queued"), lte(events.releaseAt, now)),
2394
- columns: { id: true, slug: true }
2395
- });
2396
- if (toPublish.length === 0) return;
2397
- const ids = toPublish.map((e) => e.id);
2398
- await db.update(events).set({ status: "published", publishedAt: sql`(unixepoch())` }).where(
2399
- // Drizzle doesn't have inArray for D1; use raw SQL for batch
2400
- sql`${events.id} IN (${sql.join(
2401
- ids.map((id) => sql`${id}`),
2402
- sql`, `
2403
- )})`
2404
- );
2405
- await cache.del("events:list");
2406
- await cache.del("events:etag");
2407
- console.log(
2408
- `[batch-release] Published ${toPublish.length} events:`,
2409
- toPublish.map((e) => e.slug).join(", ")
2410
- );
2411
- }
2412
- var LOCK_KEY = "cron:reconcile-slots:lock";
2413
- var LOCK_TTL = 300;
2414
- async function reconcileSlots(env) {
2415
- const db = createDb(env.DB);
2416
- const cache = new CacheService(env.KV);
2417
- const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
2418
- const slots = new SlotsService(db, cache);
2419
- const lock = await cache.get(LOCK_KEY);
2420
- if (lock) {
2421
- console.log("[reconcile-slots] Lock held, skipping.");
2422
- return;
2423
- }
2424
- await cache.set(LOCK_KEY, "1", LOCK_TTL);
2425
- try {
2426
- const publishedEvents = await db.query.events.findMany({
2427
- where: eq(events.status, "published"),
2428
- columns: { id: true, slug: true, gformsId: true, registeredSlots: true }
2429
- });
2430
- const eventsWithForms = publishedEvents.filter((e) => e.gformsId);
2431
- let corrected = 0;
2432
- for (const event of eventsWithForms) {
2433
- try {
2434
- const googleCount = await gforms.getExactResponseCount(event.gformsId);
2435
- const localCount = event.registeredSlots;
2436
- if (googleCount !== localCount) {
2437
- console.warn(
2438
- `[reconcile-slots] Drift on "${event.slug}": local=${localCount}, google=${googleCount}`
2439
- );
2440
- await slots.correctCount(event.slug, googleCount);
2441
- corrected++;
2442
- }
2443
- } catch (err) {
2444
- console.error(`[reconcile-slots] Error checking "${event.slug}":`, err);
2445
- }
2446
- }
2447
- console.log(
2448
- `[reconcile-slots] Checked ${eventsWithForms.length} events, corrected ${corrected}.`
2449
- );
2450
- } finally {
2451
- await cache.del(LOCK_KEY);
2452
- }
2453
- }
2454
- function parseStartTimestamp(dateTime, startTime) {
2455
- if (!dateTime) return null;
2456
- const combined = startTime ? `${dateTime} ${startTime}` : dateTime;
2457
- const ms = Date.parse(combined);
2458
- return Number.isNaN(ms) ? null : Math.floor(ms / 1e3);
2459
- }
2460
- async function reminderEmails(env) {
2461
- if (!env.EMAIL_QUEUE) {
2462
- console.warn(
2463
- "[reminder-emails] EMAIL_QUEUE binding not configured, skipping."
2464
- );
2465
- return;
2466
- }
2467
- const hasSes = !!(env.SES_REGION && env.SES_ACCESS_KEY_ID && env.SES_SECRET_ACCESS_KEY);
2468
- const hasResend = !!env.RESEND_API_KEY;
2469
- if (!hasSes && !hasResend) {
2470
- console.warn(
2471
- "[reminder-emails] No email providers configured. Skipping reminders."
2472
- );
2473
- return;
2474
- }
2475
- const db = createDb(env.DB);
2476
- const now = Math.floor(Date.now() / 1e3);
2477
- const candidates24h = await db.query.events.findMany({
2478
- where: and(
2479
- eq(events.status, "published"),
2480
- eq(events.reminder24hSent, false)
2481
- ),
2482
- columns: {
2483
- id: true,
2484
- dateTime: true,
2485
- startTime: true
2486
- }
2487
- });
2488
- for (const event of candidates24h) {
2489
- const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
2490
- if (!startsAt) continue;
2491
- const hoursUntil = (startsAt - now) / 3600;
2492
- if (hoursUntil <= 25 && hoursUntil >= 23) {
2493
- await env.EMAIL_QUEUE.send({
2494
- type: "send_reminder_email",
2495
- payload: { eventId: event.id, hoursBeforeEvent: 24 }
2496
- });
2497
- }
2498
- }
2499
- const candidates1h = await db.query.events.findMany({
2500
- where: and(
2501
- eq(events.status, "published"),
2502
- eq(events.reminder1hSent, false)
2503
- ),
2504
- columns: {
2505
- id: true,
2506
- dateTime: true,
2507
- startTime: true
2508
- }
2509
- });
2510
- for (const event of candidates1h) {
2511
- const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
2512
- if (!startsAt) continue;
2513
- const hoursUntil = (startsAt - now) / 3600;
2514
- if (hoursUntil <= 1.5 && hoursUntil >= 0) {
2515
- await env.EMAIL_QUEUE.send({
2516
- type: "send_reminder_email",
2517
- payload: { eventId: event.id, hoursBeforeEvent: 1 }
2518
- });
2519
- }
2520
- }
2521
- }
2522
- var RENEWAL_WINDOW = 86400;
2523
- async function renewWatches(env) {
2524
- const db = createDb(env.DB);
2525
- const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
2526
- const now = Math.floor(Date.now() / 1e3);
2527
- const threshold = now + RENEWAL_WINDOW;
2528
- const expiring = await db.query.events.findMany({
2529
- where: and(
2530
- eq(events.status, "published"),
2531
- lte(events.watchExpiresAt, threshold)
2532
- ),
2533
- columns: { id: true, slug: true, gformsId: true, watchId: true, watchExpiresAt: true }
2534
- });
2535
- const watchEvents = expiring.filter((e) => e.gformsId && e.watchId);
2536
- let renewed = 0;
2537
- for (const event of watchEvents) {
2538
- try {
2539
- const result = await gforms.renewWatch(event.gformsId, event.watchId);
2540
- const newExpiry = Math.floor(new Date(result.expireTime).getTime() / 1e3);
2541
- await db.update(events).set({ watchExpiresAt: newExpiry }).where(eq(events.id, event.id));
2542
- renewed++;
2543
- console.log(`[renew-watches] Renewed Watch for "${event.slug}", expires ${result.expireTime}`);
2544
- } catch (err) {
2545
- console.error(`[renew-watches] Failed to renew Watch for "${event.slug}":`, err);
2546
- }
2547
- }
2548
- console.log(`[renew-watches] Renewed ${renewed}/${watchEvents.length} watches.`);
2549
- }
2550
2606
 
2551
2607
  // src/db/migrate.ts
2552
2608
  var PATCH_STATEMENTS = [