@access-dlsu/leapify 0.260601.1 → 0.260602.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/app.d.ts.map +1 -1
  2. package/dist/auth/auth.d.ts +2 -2
  3. package/dist/{chunk-2JEY6TSO.js → chunk-WTA2QGY5.js} +4 -2
  4. package/dist/chunk-WTA2QGY5.js.map +1 -0
  5. package/dist/{chunk-X4OB4DZ3.cjs → chunk-ZV4TIJXI.cjs} +4 -2
  6. package/dist/chunk-ZV4TIJXI.cjs.map +1 -0
  7. package/dist/client/auth.d.ts +41 -41
  8. package/dist/client/index.cjs +7 -2
  9. package/dist/client/index.cjs.map +1 -1
  10. package/dist/client/index.d.ts +10 -1
  11. package/dist/client/index.d.ts.map +1 -1
  12. package/dist/client/index.js +7 -2
  13. package/dist/client/index.js.map +1 -1
  14. package/dist/client/types.d.ts +4 -0
  15. package/dist/client/types.d.ts.map +1 -1
  16. package/dist/cron/reconcile-slots.d.ts.map +1 -1
  17. package/dist/db/schema/themes.d.ts +74 -0
  18. package/dist/db/schema/themes.d.ts.map +1 -1
  19. package/dist/index.cjs +251 -179
  20. package/dist/index.cjs.map +1 -1
  21. package/dist/index.js +251 -179
  22. package/dist/index.js.map +1 -1
  23. package/dist/lib/middleware/turnstile-challenge.cjs +6 -6
  24. package/dist/lib/middleware/turnstile-challenge.d.ts.map +1 -1
  25. package/dist/lib/middleware/turnstile-challenge.js +1 -1
  26. package/dist/routes/internal/batch-release.d.ts +4 -0
  27. package/dist/routes/internal/batch-release.d.ts.map +1 -0
  28. package/dist/routes/internal/reconcile-slots.d.ts +4 -0
  29. package/dist/routes/internal/reconcile-slots.d.ts.map +1 -0
  30. package/dist/routes/internal/reminder-emails.d.ts +4 -0
  31. package/dist/routes/internal/reminder-emails.d.ts.map +1 -0
  32. package/dist/routes/internal/renew-watches.d.ts +4 -0
  33. package/dist/routes/internal/renew-watches.d.ts.map +1 -0
  34. package/dist/routes/themes.d.ts.map +1 -1
  35. package/dist/services/gforms.d.ts.map +1 -1
  36. package/dist/worker.js +249 -175
  37. package/dist/worker.js.map +1 -1
  38. package/package.json +2 -2
  39. package/dist/chunk-2JEY6TSO.js.map +0 -1
  40. package/dist/chunk-X4OB4DZ3.cjs.map +0 -1
@@ -1,29 +1,29 @@
1
1
  'use strict';
2
2
 
3
- var chunkX4OB4DZ3_cjs = require('../../chunk-X4OB4DZ3.cjs');
3
+ var chunkZV4TIJXI_cjs = require('../../chunk-ZV4TIJXI.cjs');
4
4
  require('../../chunk-Q7SFCCGT.cjs');
5
5
 
6
6
 
7
7
 
8
8
  Object.defineProperty(exports, "TURNSTILE_COOKIE_NAME", {
9
9
  enumerable: true,
10
- get: function () { return chunkX4OB4DZ3_cjs.TURNSTILE_COOKIE_NAME; }
10
+ get: function () { return chunkZV4TIJXI_cjs.TURNSTILE_COOKIE_NAME; }
11
11
  });
12
12
  Object.defineProperty(exports, "TURNSTILE_PATH", {
13
13
  enumerable: true,
14
- get: function () { return chunkX4OB4DZ3_cjs.TURNSTILE_PATH; }
14
+ get: function () { return chunkZV4TIJXI_cjs.TURNSTILE_PATH; }
15
15
  });
16
16
  Object.defineProperty(exports, "TURNSTILE_VERIFY_PATH", {
17
17
  enumerable: true,
18
- get: function () { return chunkX4OB4DZ3_cjs.TURNSTILE_VERIFY_PATH; }
18
+ get: function () { return chunkZV4TIJXI_cjs.TURNSTILE_VERIFY_PATH; }
19
19
  });
20
20
  Object.defineProperty(exports, "createTurnstileMiddleware", {
21
21
  enumerable: true,
22
- get: function () { return chunkX4OB4DZ3_cjs.createTurnstileMiddleware; }
22
+ get: function () { return chunkZV4TIJXI_cjs.createTurnstileMiddleware; }
23
23
  });
24
24
  Object.defineProperty(exports, "handleTurnstileVerify", {
25
25
  enumerable: true,
26
- get: function () { return chunkX4OB4DZ3_cjs.handleTurnstileVerify; }
26
+ get: function () { return chunkZV4TIJXI_cjs.handleTurnstileVerify; }
27
27
  });
28
28
  //# sourceMappingURL=turnstile-challenge.cjs.map
29
29
  //# sourceMappingURL=turnstile-challenge.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"turnstile-challenge.d.ts","sourceRoot":"","sources":["../../../src/lib/middleware/turnstile-challenge.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AACnC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAElD,eAAO,MAAM,cAAc,mCAAmC,CAAA;AAE9D,eAAO,MAAM,qBAAqB,0CAA6B,CAAA;AAE/D,eAAO,MAAM,qBAAqB,sBAAsB,CAAA;AAyHxD;;;;GAIG;AACH,wBAAsB,qBAAqB,CACzC,CAAC,EAAE,OAAO,CAAC;IAAE,QAAQ,EAAE,eAAe,CAAA;CAAE,CAAC;;;;;;;;;;;;;;;;;;oEA6C1C;AAED;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB;cACH,eAAe;yBA2BpD"}
1
+ {"version":3,"file":"turnstile-challenge.d.ts","sourceRoot":"","sources":["../../../src/lib/middleware/turnstile-challenge.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AACnC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAElD,eAAO,MAAM,cAAc,mCAAmC,CAAA;AAE9D,eAAO,MAAM,qBAAqB,0CAA6B,CAAA;AAE/D,eAAO,MAAM,qBAAqB,sBAAsB,CAAA;AA2HxD;;;;GAIG;AACH,wBAAsB,qBAAqB,CACzC,CAAC,EAAE,OAAO,CAAC;IAAE,QAAQ,EAAE,eAAe,CAAA;CAAE,CAAC;;;;;;;;;;;;;;;;;;oEA6C1C;AAED;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB;cACH,eAAe;yBA2BpD"}
@@ -1,4 +1,4 @@
1
- export { TURNSTILE_COOKIE_NAME, TURNSTILE_PATH, TURNSTILE_VERIFY_PATH, createTurnstileMiddleware, handleTurnstileVerify } from '../../chunk-2JEY6TSO.js';
1
+ export { TURNSTILE_COOKIE_NAME, TURNSTILE_PATH, TURNSTILE_VERIFY_PATH, createTurnstileMiddleware, handleTurnstileVerify } from '../../chunk-WTA2QGY5.js';
2
2
  import '../../chunk-PZ5AY32C.js';
3
3
  //# sourceMappingURL=turnstile-challenge.js.map
4
4
  //# sourceMappingURL=turnstile-challenge.js.map
@@ -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":"themes.d.ts","sourceRoot":"","sources":["../../src/routes/themes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAI3B,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAW1C,eAAO,MAAM,WAAW,yDAAyB,CAAA"}
1
+ {"version":3,"file":"themes.d.ts","sourceRoot":"","sources":["../../src/routes/themes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAI3B,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AA+B1C,eAAO,MAAM,WAAW,yDAAyB,CAAA"}
@@ -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, asc, 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';
@@ -24,8 +24,6 @@ var LeapifyError = class extends Error {
24
24
  this.code = code;
25
25
  this.name = "LeapifyError";
26
26
  }
27
- statusCode;
28
- code;
29
27
  };
30
28
  var unauthorized = (message = "Unauthorized") => new LeapifyError(401, "UNAUTHORIZED", message);
31
29
  var domainRestricted = () => new LeapifyError(
@@ -91,6 +89,10 @@ var themes = sqliteTable("themes", {
91
89
  name: text("name").notNull().unique(),
92
90
  path: text("path").notNull().unique(),
93
91
  // e.g. "/pirates-cove"
92
+ imageUrl: text("image_url"),
93
+ descriptionEn: text("description_en"),
94
+ descriptionFil: text("description_fil"),
95
+ sortOrder: integer("sort_order").notNull().default(0),
94
96
  createdAt: integer("created_at").notNull().$defaultFn(() => Math.floor(Date.now() / 1e3)),
95
97
  updatedAt: integer("updated_at").notNull().$defaultFn(() => Math.floor(Date.now() / 1e3))
96
98
  });
@@ -405,6 +407,8 @@ var EXEMPT_PATHS = [
405
407
  "/api/classes",
406
408
  "/api/faqs",
407
409
  "/api/config",
410
+ "/api/themes",
411
+ "/api/organizations",
408
412
  TURNSTILE_VERIFY_PATH
409
413
  ];
410
414
  function base64urlEncode(bytes) {
@@ -906,7 +910,6 @@ var CacheService = class {
906
910
  constructor(kv) {
907
911
  this.kv = kv;
908
912
  }
909
- kv;
910
913
  async get(key) {
911
914
  return this.kv.get(key, "json");
912
915
  }
@@ -952,8 +955,6 @@ var SlotsService = class {
952
955
  this.db = db;
953
956
  this.cache = cache;
954
957
  }
955
- db;
956
- cache;
957
958
  kvKey(slug) {
958
959
  return `${SLOT_KV_PREFIX}${slug}`;
959
960
  }
@@ -1108,7 +1109,10 @@ var GFormsService = class {
1108
1109
  const response = await fetch(url.toString(), {
1109
1110
  headers: { Authorization: `Bearer ${token}` }
1110
1111
  });
1111
- if (!response.ok) throw new Error(`Forms API error: ${response.status}`);
1112
+ if (!response.ok) {
1113
+ const err = await response.text();
1114
+ throw new Error(`Forms API error: ${response.status} ${err}`);
1115
+ }
1112
1116
  const data = await response.json();
1113
1117
  allResponses.push(...data.responses ?? []);
1114
1118
  pageToken = data.nextPageToken;
@@ -1382,6 +1386,27 @@ classesRoute.get("/:slug/slots", eventsSlotsRateLimit, async (c) => {
1382
1386
  c.header("Cache-Control", "public, max-age=5, stale-while-revalidate=5");
1383
1387
  return c.json({ data: info });
1384
1388
  });
1389
+ classesRoute.post("/:slug/reconcile", authMiddleware, adminMiddleware, async (c) => {
1390
+ const { slug } = c.req.param();
1391
+ const db = createDb(c.env.DB);
1392
+ const cache = new CacheService(c.env.KV);
1393
+ const gforms = new GFormsService(c.env.GFORMS_SERVICE_ACCOUNT_JSON);
1394
+ const slots = new SlotsService(db, cache);
1395
+ const event = await db.query.events.findFirst({
1396
+ where: eq(events.slug, slug),
1397
+ columns: { gformsId: true }
1398
+ });
1399
+ if (!event) throw notFound("Event");
1400
+ if (!event.gformsId) return c.json({ error: "No gformsId set for this event" }, 400);
1401
+ try {
1402
+ const googleCount = await gforms.getExactResponseCount(event.gformsId);
1403
+ await slots.correctCount(slug, googleCount);
1404
+ return c.json({ data: { registeredSlots: googleCount } });
1405
+ } catch (err) {
1406
+ const message = err?.message ?? "Failed to fetch from Google Forms API";
1407
+ return c.json({ error: { code: "GFORMS_API_ERROR", message } }, 502);
1408
+ }
1409
+ });
1385
1410
  classesRoute.post(
1386
1411
  "/",
1387
1412
  authMiddleware,
@@ -1587,7 +1612,7 @@ faqsRoute.get("/", async (c) => {
1587
1612
  const data = await cache.getOrSet(
1588
1613
  FAQS_KV_KEY,
1589
1614
  () => db.query.faqs.findMany({
1590
- orderBy: (t, { asc }) => [asc(t.sortOrder), asc(t.createdAt)]
1615
+ orderBy: (t, { asc: asc2 }) => [asc2(t.sortOrder), asc2(t.createdAt)]
1591
1616
  }),
1592
1617
  FAQS_TTL
1593
1618
  );
@@ -1689,6 +1714,196 @@ async function verifyGoogSignature(body, signature, secret) {
1689
1714
  return false;
1690
1715
  }
1691
1716
  }
1717
+ var LOCK_KEY = "cron:reconcile-slots:lock";
1718
+ var LOCK_TTL = 300;
1719
+ async function reconcileSlots(env) {
1720
+ const db = createDb(env.DB);
1721
+ const cache = new CacheService(env.KV);
1722
+ const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
1723
+ const slots = new SlotsService(db, cache);
1724
+ const lock = await cache.get(LOCK_KEY);
1725
+ if (lock) {
1726
+ console.log("[reconcile-slots] Lock held, skipping.");
1727
+ return;
1728
+ }
1729
+ await cache.set(LOCK_KEY, "1", LOCK_TTL);
1730
+ try {
1731
+ const publishedEvents = await db.query.events.findMany({
1732
+ where: isNotNull(events.gformsId),
1733
+ columns: { id: true, slug: true, gformsId: true, registeredSlots: true }
1734
+ });
1735
+ const eventsWithForms = publishedEvents.filter((e) => e.gformsId);
1736
+ let corrected = 0;
1737
+ for (const event of eventsWithForms) {
1738
+ try {
1739
+ const googleCount = await gforms.getExactResponseCount(event.gformsId);
1740
+ const localCount = event.registeredSlots;
1741
+ if (googleCount !== localCount) {
1742
+ console.warn(
1743
+ `[reconcile-slots] Drift on "${event.slug}": local=${localCount}, google=${googleCount}`
1744
+ );
1745
+ await slots.correctCount(event.slug, googleCount);
1746
+ corrected++;
1747
+ }
1748
+ } catch (err) {
1749
+ console.error(`[reconcile-slots] Error checking "${event.slug}":`, err);
1750
+ }
1751
+ }
1752
+ console.log(
1753
+ `[reconcile-slots] Checked ${eventsWithForms.length} events, corrected ${corrected}.`
1754
+ );
1755
+ } finally {
1756
+ await cache.del(LOCK_KEY);
1757
+ }
1758
+ }
1759
+
1760
+ // src/routes/internal/reconcile-slots.ts
1761
+ var reconcileSlotsRoute = new Hono();
1762
+ reconcileSlotsRoute.post("/", internalMiddleware, async (c) => {
1763
+ await reconcileSlots(c.env);
1764
+ return c.json({ ok: true });
1765
+ });
1766
+ async function batchRelease(env) {
1767
+ const db = createDb(env.DB);
1768
+ const cache = new CacheService(env.KV);
1769
+ const now = Math.floor(Date.now() / 1e3);
1770
+ const toPublish = await db.query.events.findMany({
1771
+ where: and(eq(events.status, "queued"), lte(events.releaseAt, now)),
1772
+ columns: { id: true, slug: true }
1773
+ });
1774
+ if (toPublish.length === 0) return;
1775
+ const ids = toPublish.map((e) => e.id);
1776
+ await db.update(events).set({ status: "published", publishedAt: sql`(unixepoch())` }).where(
1777
+ // Drizzle doesn't have inArray for D1; use raw SQL for batch
1778
+ sql`${events.id} IN (${sql.join(
1779
+ ids.map((id) => sql`${id}`),
1780
+ sql`, `
1781
+ )})`
1782
+ );
1783
+ await cache.del("events:list");
1784
+ await cache.del("events:etag");
1785
+ console.log(
1786
+ `[batch-release] Published ${toPublish.length} events:`,
1787
+ toPublish.map((e) => e.slug).join(", ")
1788
+ );
1789
+ }
1790
+
1791
+ // src/routes/internal/batch-release.ts
1792
+ var batchReleaseRoute = new Hono();
1793
+ batchReleaseRoute.post("/", internalMiddleware, async (c) => {
1794
+ await batchRelease(c.env);
1795
+ return c.json({ ok: true });
1796
+ });
1797
+ function parseStartTimestamp(dateTime, startTime) {
1798
+ if (!dateTime) return null;
1799
+ const combined = startTime ? `${dateTime} ${startTime}` : dateTime;
1800
+ const ms = Date.parse(combined);
1801
+ return Number.isNaN(ms) ? null : Math.floor(ms / 1e3);
1802
+ }
1803
+ async function reminderEmails(env) {
1804
+ if (!env.EMAIL_QUEUE) {
1805
+ console.warn(
1806
+ "[reminder-emails] EMAIL_QUEUE binding not configured, skipping."
1807
+ );
1808
+ return;
1809
+ }
1810
+ const hasSes = !!(env.SES_REGION && env.SES_ACCESS_KEY_ID && env.SES_SECRET_ACCESS_KEY);
1811
+ const hasResend = !!env.RESEND_API_KEY;
1812
+ if (!hasSes && !hasResend) {
1813
+ console.warn(
1814
+ "[reminder-emails] No email providers configured. Skipping reminders."
1815
+ );
1816
+ return;
1817
+ }
1818
+ const db = createDb(env.DB);
1819
+ const now = Math.floor(Date.now() / 1e3);
1820
+ const candidates24h = await db.query.events.findMany({
1821
+ where: and(
1822
+ eq(events.status, "published"),
1823
+ eq(events.reminder24hSent, false)
1824
+ ),
1825
+ columns: {
1826
+ id: true,
1827
+ dateTime: true,
1828
+ startTime: true
1829
+ }
1830
+ });
1831
+ for (const event of candidates24h) {
1832
+ const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
1833
+ if (!startsAt) continue;
1834
+ const hoursUntil = (startsAt - now) / 3600;
1835
+ if (hoursUntil <= 25 && hoursUntil >= 23) {
1836
+ await env.EMAIL_QUEUE.send({
1837
+ type: "send_reminder_email",
1838
+ payload: { eventId: event.id, hoursBeforeEvent: 24 }
1839
+ });
1840
+ }
1841
+ }
1842
+ const candidates1h = await db.query.events.findMany({
1843
+ where: and(
1844
+ eq(events.status, "published"),
1845
+ eq(events.reminder1hSent, false)
1846
+ ),
1847
+ columns: {
1848
+ id: true,
1849
+ dateTime: true,
1850
+ startTime: true
1851
+ }
1852
+ });
1853
+ for (const event of candidates1h) {
1854
+ const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
1855
+ if (!startsAt) continue;
1856
+ const hoursUntil = (startsAt - now) / 3600;
1857
+ if (hoursUntil <= 1.5 && hoursUntil >= 0) {
1858
+ await env.EMAIL_QUEUE.send({
1859
+ type: "send_reminder_email",
1860
+ payload: { eventId: event.id, hoursBeforeEvent: 1 }
1861
+ });
1862
+ }
1863
+ }
1864
+ }
1865
+
1866
+ // src/routes/internal/reminder-emails.ts
1867
+ var reminderEmailsRoute = new Hono();
1868
+ reminderEmailsRoute.post("/", internalMiddleware, async (c) => {
1869
+ await reminderEmails(c.env);
1870
+ return c.json({ ok: true });
1871
+ });
1872
+ var RENEWAL_WINDOW = 86400;
1873
+ async function renewWatches(env) {
1874
+ const db = createDb(env.DB);
1875
+ const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
1876
+ const now = Math.floor(Date.now() / 1e3);
1877
+ const threshold = now + RENEWAL_WINDOW;
1878
+ const expiring = await db.query.events.findMany({
1879
+ where: and(
1880
+ eq(events.status, "published"),
1881
+ lte(events.watchExpiresAt, threshold)
1882
+ ),
1883
+ columns: { id: true, slug: true, gformsId: true, watchId: true, watchExpiresAt: true }
1884
+ });
1885
+ const watchEvents = expiring.filter((e) => e.gformsId && e.watchId);
1886
+ let renewed = 0;
1887
+ for (const event of watchEvents) {
1888
+ try {
1889
+ const result = await gforms.renewWatch(event.gformsId, event.watchId);
1890
+ const newExpiry = Math.floor(new Date(result.expireTime).getTime() / 1e3);
1891
+ await db.update(events).set({ watchExpiresAt: newExpiry }).where(eq(events.id, event.id));
1892
+ renewed++;
1893
+ console.log(`[renew-watches] Renewed Watch for "${event.slug}", expires ${result.expireTime}`);
1894
+ } catch (err) {
1895
+ console.error(`[renew-watches] Failed to renew Watch for "${event.slug}":`, err);
1896
+ }
1897
+ }
1898
+ console.log(`[renew-watches] Renewed ${renewed}/${watchEvents.length} watches.`);
1899
+ }
1900
+
1901
+ // src/routes/internal/renew-watches.ts
1902
+ var renewWatchesRoute = new Hono();
1903
+ renewWatchesRoute.post("/", internalMiddleware, async (c) => {
1904
+ await renewWatches(c.env);
1905
+ return c.json({ ok: true });
1906
+ });
1692
1907
  var ALLOWED_MIME_TYPES = /* @__PURE__ */ new Set([
1693
1908
  "image/jpeg",
1694
1909
  "image/png",
@@ -1788,14 +2003,27 @@ function extensionFromMime(mime) {
1788
2003
  };
1789
2004
  return map[mime] ?? "bin";
1790
2005
  }
2006
+ function generatePath(name) {
2007
+ return name.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
2008
+ }
1791
2009
  var createThemeSchema = z.object({
1792
2010
  name: z.string().min(1),
1793
- path: z.string().min(1)
2011
+ imageUrl: z.string().url().nullable().optional(),
2012
+ descriptionEn: z.string().nullable().optional(),
2013
+ descriptionFil: z.string().nullable().optional(),
2014
+ sortOrder: z.number().int().default(0)
2015
+ });
2016
+ z.object({
2017
+ name: z.string().min(1).optional(),
2018
+ imageUrl: z.string().url().nullable().optional(),
2019
+ descriptionEn: z.string().nullable().optional(),
2020
+ descriptionFil: z.string().nullable().optional(),
2021
+ sortOrder: z.number().int().optional()
1794
2022
  });
1795
2023
  var themesRoute = new Hono();
1796
2024
  themesRoute.get("/", async (c) => {
1797
2025
  const db = createDb(c.env.DB);
1798
- const data = await db.select().from(themes);
2026
+ const data = await db.select().from(themes).orderBy(asc(themes.sortOrder), asc(themes.createdAt));
1799
2027
  return c.json({ data });
1800
2028
  });
1801
2029
  themesRoute.post(
@@ -1806,8 +2034,9 @@ themesRoute.post(
1806
2034
  async (c) => {
1807
2035
  const body = c.req.valid("json");
1808
2036
  const db = createDb(c.env.DB);
2037
+ const path = generatePath(body.name);
1809
2038
  try {
1810
- const [created] = await db.insert(themes).values(body).returning();
2039
+ const [created] = await db.insert(themes).values({ ...body, path }).returning();
1811
2040
  return c.json({ data: created }, 201);
1812
2041
  } catch (err) {
1813
2042
  if (err.message && err.message.includes("UNIQUE constraint failed")) {
@@ -1825,8 +2054,12 @@ themesRoute.patch(
1825
2054
  const { id } = c.req.param();
1826
2055
  const body = await c.req.json();
1827
2056
  const db = createDb(c.env.DB);
2057
+ const update = { ...body };
2058
+ if (body.name) {
2059
+ update.path = generatePath(body.name);
2060
+ }
1828
2061
  try {
1829
- const [updated] = await db.update(themes).set(body).where(eq(themes.id, id)).returning();
2062
+ const [updated] = await db.update(themes).set(update).where(eq(themes.id, id)).returning();
1830
2063
  if (!updated) throw notFound("Theme");
1831
2064
  return c.json({ data: updated });
1832
2065
  } catch (err) {
@@ -1955,6 +2188,10 @@ function createApp(options = {}) {
1955
2188
  app2.route("/api/faqs", faqsRoute);
1956
2189
  app2.route("/api/uploads", uploadsRoute);
1957
2190
  app2.route("/internal/gforms-webhook", gformsWebhookRoute);
2191
+ app2.route("/internal/reconcile-slots", reconcileSlotsRoute);
2192
+ app2.route("/internal/batch-release", batchReleaseRoute);
2193
+ app2.route("/internal/reminder-emails", reminderEmailsRoute);
2194
+ app2.route("/internal/renew-watches", renewWatchesRoute);
1958
2195
  app2.onError(errorHandler);
1959
2196
  app2.notFound(
1960
2197
  (c) => c.json({ error: { code: "NOT_FOUND", message: "Route not found" } }, 404)
@@ -2060,7 +2297,6 @@ var SesError = class extends Error {
2060
2297
  this.status = status;
2061
2298
  this.name = "SesError";
2062
2299
  }
2063
- status;
2064
2300
  /**
2065
2301
  * True for errors that are permanent (not worth retrying via SES again).
2066
2302
  * 400 BadRequest, 403 Forbidden, 404 NotFound → non-retryable.
@@ -2385,168 +2621,6 @@ async function processJob(job, services) {
2385
2621
  }
2386
2622
  }
2387
2623
  }
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
2624
 
2551
2625
  // src/db/migrate.ts
2552
2626
  var PATCH_STATEMENTS = [