@access-dlsu/leapify 0.260505.5 → 0.260507.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 (199) hide show
  1. package/dist/app.d.ts +15 -0
  2. package/dist/app.d.ts.map +1 -0
  3. package/dist/auth/auth.d.ts +99 -0
  4. package/dist/auth/auth.d.ts.map +1 -0
  5. package/dist/auth/middleware.d.ts +20 -0
  6. package/dist/auth/middleware.d.ts.map +1 -0
  7. package/dist/auth/types.d.ts +17 -0
  8. package/dist/auth/types.d.ts.map +1 -0
  9. package/dist/bun-sqlite-dialect-na--YwnN-NIYANHVJ.cjs +162 -0
  10. package/dist/bun-sqlite-dialect-na--YwnN-NIYANHVJ.cjs.map +1 -0
  11. package/dist/bun-sqlite-dialect-na--YwnN-XVQNOKSL.js +160 -0
  12. package/dist/bun-sqlite-dialect-na--YwnN-XVQNOKSL.js.map +1 -0
  13. package/dist/chunk-4DPT2KQR.cjs +467 -0
  14. package/dist/chunk-4DPT2KQR.cjs.map +1 -0
  15. package/dist/chunk-5JKLV7IE.cjs +2962 -0
  16. package/dist/chunk-5JKLV7IE.cjs.map +1 -0
  17. package/dist/chunk-5OQD5ALM.cjs +76 -0
  18. package/dist/chunk-5OQD5ALM.cjs.map +1 -0
  19. package/dist/{chunk-FLR7TNLN.js → chunk-63CUZGSZ.js} +4 -12
  20. package/dist/chunk-63CUZGSZ.js.map +1 -0
  21. package/dist/chunk-6MMWL46O.cjs +7170 -0
  22. package/dist/chunk-6MMWL46O.cjs.map +1 -0
  23. package/dist/chunk-ANNHE3PZ.js +2285 -0
  24. package/dist/chunk-ANNHE3PZ.js.map +1 -0
  25. package/dist/chunk-EGRHWZRV.js +3 -0
  26. package/dist/chunk-EGRHWZRV.js.map +1 -0
  27. package/dist/chunk-EMMSS5I5.cjs +37 -0
  28. package/dist/chunk-EMMSS5I5.cjs.map +1 -0
  29. package/dist/chunk-FUCJEA2S.js +6196 -0
  30. package/dist/chunk-FUCJEA2S.js.map +1 -0
  31. package/dist/chunk-G3PMV62Z.js +33 -0
  32. package/dist/chunk-G3PMV62Z.js.map +1 -0
  33. package/dist/chunk-GNRL67OU.js +2949 -0
  34. package/dist/chunk-GNRL67OU.js.map +1 -0
  35. package/dist/chunk-HHNEB7YR.js +8 -0
  36. package/dist/chunk-HHNEB7YR.js.map +1 -0
  37. package/dist/chunk-IQEWVHLM.js +889 -0
  38. package/dist/chunk-IQEWVHLM.js.map +1 -0
  39. package/dist/chunk-JIZPYG6H.js +72 -0
  40. package/dist/chunk-JIZPYG6H.js.map +1 -0
  41. package/dist/chunk-JPVIXCF5.cjs +10 -0
  42. package/dist/chunk-JPVIXCF5.cjs.map +1 -0
  43. package/dist/chunk-JQSZJWBN.cjs +3075 -0
  44. package/dist/chunk-JQSZJWBN.cjs.map +1 -0
  45. package/dist/chunk-MNEW2V4T.js +447 -0
  46. package/dist/chunk-MNEW2V4T.js.map +1 -0
  47. package/dist/chunk-MY37YE52.js +3034 -0
  48. package/dist/chunk-MY37YE52.js.map +1 -0
  49. package/dist/chunk-NKIQRCOM.cjs +4 -0
  50. package/dist/chunk-NKIQRCOM.cjs.map +1 -0
  51. package/dist/chunk-OK6RVPEH.cjs +6200 -0
  52. package/dist/chunk-OK6RVPEH.cjs.map +1 -0
  53. package/dist/chunk-QARF2YFF.cjs +2296 -0
  54. package/dist/chunk-QARF2YFF.cjs.map +1 -0
  55. package/dist/chunk-RFP2X2FA.cjs +903 -0
  56. package/dist/chunk-RFP2X2FA.cjs.map +1 -0
  57. package/dist/chunk-XJSWMHDL.js +7142 -0
  58. package/dist/chunk-XJSWMHDL.js.map +1 -0
  59. package/dist/{chunk-3ZWIJNZG.cjs → chunk-YFJBE3AU.cjs} +4 -12
  60. package/dist/chunk-YFJBE3AU.cjs.map +1 -0
  61. package/dist/client/{index.d.cts → auth.d.ts} +140 -394
  62. package/dist/client/auth.d.ts.map +1 -0
  63. package/dist/client/index.cjs +909 -48
  64. package/dist/client/index.cjs.map +1 -1
  65. package/dist/client/index.d.ts +83 -1134
  66. package/dist/client/index.d.ts.map +1 -0
  67. package/dist/client/index.js +908 -47
  68. package/dist/client/index.js.map +1 -1
  69. package/dist/client/pow.d.ts +28 -0
  70. package/dist/client/pow.d.ts.map +1 -0
  71. package/dist/client/session.d.ts +29 -0
  72. package/dist/client/session.d.ts.map +1 -0
  73. package/dist/client/types.d.ts +58 -38
  74. package/dist/client/types.d.ts.map +1 -0
  75. package/dist/cron/batch-release.d.ts +9 -0
  76. package/dist/cron/batch-release.d.ts.map +1 -0
  77. package/dist/cron/lifecycle-check.d.ts +10 -0
  78. package/dist/cron/lifecycle-check.d.ts.map +1 -0
  79. package/dist/cron/reconcile-slots.d.ts +10 -0
  80. package/dist/cron/reconcile-slots.d.ts.map +1 -0
  81. package/dist/cron/reminder-emails.d.ts +9 -0
  82. package/dist/cron/reminder-emails.d.ts.map +1 -0
  83. package/dist/cron/renew-watches.d.ts +9 -0
  84. package/dist/cron/renew-watches.d.ts.map +1 -0
  85. package/dist/d1-sqlite-dialect-C2B7YsIT-6TVV7EJ5.js +122 -0
  86. package/dist/d1-sqlite-dialect-C2B7YsIT-6TVV7EJ5.js.map +1 -0
  87. package/dist/d1-sqlite-dialect-C2B7YsIT-PE74FLHQ.cjs +124 -0
  88. package/dist/d1-sqlite-dialect-C2B7YsIT-PE74FLHQ.cjs.map +1 -0
  89. package/dist/db/index.d.ts +7 -0
  90. package/dist/db/index.d.ts.map +1 -0
  91. package/dist/db/migrate.d.ts +23 -0
  92. package/dist/db/migrate.d.ts.map +1 -0
  93. package/dist/db/schema/auth.d.ts +649 -0
  94. package/dist/db/schema/auth.d.ts.map +1 -0
  95. package/dist/db/schema/bookmarks.d.ts +88 -0
  96. package/dist/db/schema/bookmarks.d.ts.map +1 -0
  97. package/dist/db/schema/events.d.ts +546 -0
  98. package/dist/db/schema/events.d.ts.map +1 -0
  99. package/dist/db/schema/faqs.d.ts +137 -0
  100. package/dist/db/schema/faqs.d.ts.map +1 -0
  101. package/dist/db/schema/index.d.ts +9 -0
  102. package/dist/db/schema/index.d.ts.map +1 -0
  103. package/dist/db/schema/organizations.d.ts +125 -0
  104. package/dist/db/schema/organizations.d.ts.map +1 -0
  105. package/dist/db/schema/site-config.d.ts +64 -0
  106. package/dist/db/schema/site-config.d.ts.map +1 -0
  107. package/dist/db/schema/themes.d.ts +104 -0
  108. package/dist/db/schema/themes.d.ts.map +1 -0
  109. package/dist/{types-lTjxCp88.d.cts → db/schema/users.d.ts} +11 -96
  110. package/dist/db/schema/users.d.ts.map +1 -0
  111. package/dist/dist-DZHA5VYX.cjs +260 -0
  112. package/dist/dist-DZHA5VYX.cjs.map +1 -0
  113. package/dist/dist-RRQUBLLO.js +258 -0
  114. package/dist/dist-RRQUBLLO.js.map +1 -0
  115. package/dist/index.cjs +38065 -937
  116. package/dist/index.cjs.map +1 -1
  117. package/dist/index.d.ts +23 -1818
  118. package/dist/index.d.ts.map +1 -0
  119. package/dist/index.js +37940 -816
  120. package/dist/index.js.map +1 -1
  121. package/dist/kysely-adapter-C76KJVG7.js +10 -0
  122. package/dist/kysely-adapter-C76KJVG7.js.map +1 -0
  123. package/dist/kysely-adapter-TGY4UUP5.cjs +27 -0
  124. package/dist/kysely-adapter-TGY4UUP5.cjs.map +1 -0
  125. package/dist/lib/errors.d.ts +15 -0
  126. package/dist/lib/errors.d.ts.map +1 -0
  127. package/dist/lib/middleware/cors.d.ts +3 -0
  128. package/dist/lib/middleware/cors.d.ts.map +1 -0
  129. package/dist/lib/middleware/error-handler.d.ts +3 -0
  130. package/dist/lib/middleware/error-handler.d.ts.map +1 -0
  131. package/dist/lib/middleware/pow-challenge.cjs +7 -7
  132. package/dist/lib/middleware/pow-challenge.d.ts +29 -18
  133. package/dist/lib/middleware/pow-challenge.d.ts.map +1 -0
  134. package/dist/lib/middleware/pow-challenge.js +2 -2
  135. package/dist/lib/middleware/rate-limit.d.ts +48 -0
  136. package/dist/lib/middleware/rate-limit.d.ts.map +1 -0
  137. package/dist/lib/middleware/referer-guard.d.ts +18 -0
  138. package/dist/lib/middleware/referer-guard.d.ts.map +1 -0
  139. package/dist/lib/retry.d.ts +12 -0
  140. package/dist/lib/retry.d.ts.map +1 -0
  141. package/dist/node-sqlite-dialect-B3H37T3R.cjs +162 -0
  142. package/dist/node-sqlite-dialect-B3H37T3R.cjs.map +1 -0
  143. package/dist/node-sqlite-dialect-GDP7ZE54.js +160 -0
  144. package/dist/node-sqlite-dialect-GDP7ZE54.js.map +1 -0
  145. package/dist/queues/handlers.d.ts +13 -0
  146. package/dist/queues/handlers.d.ts.map +1 -0
  147. package/dist/queues/jobs.d.ts +42 -0
  148. package/dist/queues/jobs.d.ts.map +1 -0
  149. package/dist/routes/events.d.ts +4 -0
  150. package/dist/routes/events.d.ts.map +1 -0
  151. package/dist/routes/faqs.d.ts +4 -0
  152. package/dist/routes/faqs.d.ts.map +1 -0
  153. package/dist/routes/health.d.ts +4 -0
  154. package/dist/routes/health.d.ts.map +1 -0
  155. package/dist/routes/internal/gforms-webhook.d.ts +4 -0
  156. package/dist/routes/internal/gforms-webhook.d.ts.map +1 -0
  157. package/dist/routes/organizations.d.ts +4 -0
  158. package/dist/routes/organizations.d.ts.map +1 -0
  159. package/dist/routes/site-config.d.ts +4 -0
  160. package/dist/routes/site-config.d.ts.map +1 -0
  161. package/dist/routes/themes.d.ts +4 -0
  162. package/dist/routes/themes.d.ts.map +1 -0
  163. package/dist/routes/uploads.d.ts +4 -0
  164. package/dist/routes/uploads.d.ts.map +1 -0
  165. package/dist/routes/users.d.ts +4 -0
  166. package/dist/routes/users.d.ts.map +1 -0
  167. package/dist/services/cache.d.ts +22 -0
  168. package/dist/services/cache.d.ts.map +1 -0
  169. package/dist/services/contentful-management.d.ts +38 -0
  170. package/dist/services/contentful-management.d.ts.map +1 -0
  171. package/dist/services/contentful.d.ts +97 -0
  172. package/dist/services/contentful.d.ts.map +1 -0
  173. package/dist/services/email.d.ts +75 -0
  174. package/dist/services/email.d.ts.map +1 -0
  175. package/dist/services/gforms.d.ts +60 -0
  176. package/dist/services/gforms.d.ts.map +1 -0
  177. package/dist/services/resend.d.ts +35 -0
  178. package/dist/services/resend.d.ts.map +1 -0
  179. package/dist/services/ses.d.ts +58 -0
  180. package/dist/services/ses.d.ts.map +1 -0
  181. package/dist/services/slots.d.ts +48 -0
  182. package/dist/services/slots.d.ts.map +1 -0
  183. package/dist/services/snapshot.d.ts +95 -0
  184. package/dist/services/snapshot.d.ts.map +1 -0
  185. package/dist/types.d.ts +66 -0
  186. package/dist/types.d.ts.map +1 -0
  187. package/dist/worker-handler.d.ts +64 -0
  188. package/dist/worker-handler.d.ts.map +1 -0
  189. package/dist/worker.d.ts +36 -0
  190. package/dist/worker.d.ts.map +1 -0
  191. package/dist/worker.js +1172 -583
  192. package/dist/worker.js.map +1 -1
  193. package/package.json +153 -152
  194. package/dist/chunk-3ZWIJNZG.cjs.map +0 -1
  195. package/dist/chunk-FLR7TNLN.js.map +0 -1
  196. package/dist/client/types.d.cts +0 -192
  197. package/dist/index.d.cts +0 -1879
  198. package/dist/lib/middleware/pow-challenge.d.cts +0 -47
  199. package/dist/types-lTjxCp88.d.ts +0 -208
package/dist/worker.js CHANGED
@@ -1,5 +1,4 @@
1
- import crypto2 from 'crypto';
2
- import fs from 'fs';
1
+ import { createClient } from 'contentful-management';
3
2
 
4
3
  var __create = Object.create;
5
4
  var __defProp = Object.defineProperty;
@@ -25092,14 +25091,11 @@ var errorHandler2 = (err, c) => {
25092
25091
 
25093
25092
  // node_modules/hono/dist/middleware/cors/index.js
25094
25093
  var cors = (options) => {
25095
- const defaults = {
25094
+ const opts = {
25096
25095
  origin: "*",
25097
25096
  allowMethods: ["GET", "HEAD", "PUT", "POST", "DELETE", "PATCH"],
25098
25097
  allowHeaders: [],
25099
- exposeHeaders: []
25100
- };
25101
- const opts = {
25102
- ...defaults,
25098
+ exposeHeaders: [],
25103
25099
  ...options
25104
25100
  };
25105
25101
  const findAllowOrigin = ((optsOrigin) => {
@@ -25332,19 +25328,11 @@ function challengePageHtml(challengeId, difficulty, originalUrl) {
25332
25328
  const challengeId = ${JSON.stringify(challengeId)};
25333
25329
  const difficulty = ${difficulty};
25334
25330
  const prefix = '0'.repeat(Math.ceil(difficulty / 4));
25335
- const data = new TextEncoder().encode(challengeId + ':');
25336
- const buf = new ArrayBuffer(data.byteLength + 16);
25337
- new Uint8Array(buf).set(data);
25338
- const view = new DataView(buf);
25339
- view.setUint32(data.length, 0);
25340
- view.setUint32(data.length + 4, 0);
25341
- view.setUint32(data.length + 8, 0);
25342
- view.setUint32(data.length + 12, 0);
25343
25331
  let nonce = 0;
25344
25332
  const t0 = performance.now();
25345
25333
  while (true) {
25346
- view.setUint32(data.length, nonce);
25347
- const hash = await crypto.subtle.digest('SHA-256', buf);
25334
+ const input = challengeId + ':' + nonce;
25335
+ const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
25348
25336
  const hex = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
25349
25337
  if (hex.startsWith(prefix)) {
25350
25338
  const elapsed = performance.now() - t0;
@@ -62532,6 +62520,8 @@ __export(schema_exports, {
62532
62520
  events: () => events,
62533
62521
  eventsRelations: () => eventsRelations,
62534
62522
  faqs: () => faqs,
62523
+ organizations: () => organizations,
62524
+ organizationsRelations: () => organizationsRelations,
62535
62525
  siteConfig: () => siteConfig,
62536
62526
  themes: () => themes,
62537
62527
  themesRelations: () => themesRelations,
@@ -62558,13 +62548,26 @@ var themes = sqliteTable("themes", {
62558
62548
  name: text("name").notNull().unique(),
62559
62549
  path: text("path").notNull().unique(),
62560
62550
  // e.g. "/pirates-cove"
62561
- color: text("color"),
62562
- createdAt: integer2("created_at").notNull().$defaultFn(() => Math.floor(Date.now() / 1e3))
62551
+ createdAt: integer2("created_at").notNull().$defaultFn(() => Math.floor(Date.now() / 1e3)),
62552
+ updatedAt: integer2("updated_at").notNull().$defaultFn(() => Math.floor(Date.now() / 1e3))
62563
62553
  });
62564
62554
  var themesRelations = relations(themes, ({ many }) => ({
62565
62555
  events: many(events)
62566
62556
  }));
62567
62557
 
62558
+ // src/db/schema/organizations.ts
62559
+ var organizations = sqliteTable("organizations", {
62560
+ id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID().replace(/-/g, "")),
62561
+ name: text("name").notNull().unique(),
62562
+ acronym: text("acronym").notNull().unique(),
62563
+ logoUrl: text("logo_url"),
62564
+ link: text("link"),
62565
+ createdAt: integer2("created_at").notNull().default(sql2`(unixepoch())`)
62566
+ });
62567
+ var organizationsRelations = relations(organizations, ({ many }) => ({
62568
+ events: many(events)
62569
+ }));
62570
+
62568
62571
  // src/db/schema/events.ts
62569
62572
  var events = sqliteTable(
62570
62573
  "events",
@@ -62573,23 +62576,23 @@ var events = sqliteTable(
62573
62576
  slug: text("slug").notNull().unique(),
62574
62577
  // Theme reference
62575
62578
  themeId: text("theme_id").references(() => themes.id),
62579
+ // Organization reference
62580
+ organizationId: text("organization_id").references(() => organizations.id),
62576
62581
  // Core event fields (maps to LinkData)
62577
62582
  title: text("title").notNull(),
62578
- org: text("org"),
62579
- // organizing body / college
62583
+ description: text("description"),
62580
62584
  venue: text("venue"),
62581
62585
  dateTime: text("date_time"),
62582
62586
  // human-readable display string
62583
- startsAt: integer2("starts_at"),
62584
- // unix epoch (machine use)
62585
- endsAt: integer2("ends_at"),
62586
62587
  price: text("price"),
62587
62588
  // e.g. "Free" or "₱150"
62588
- backgroundColor: text("background_color"),
62589
62589
  backgroundImageUrl: text("background_image_url"),
62590
- // Extended classification
62591
- subtheme: text("subtheme"),
62592
- // freeform, e.g. "Leadership"
62590
+ classCode: text("class_code"),
62591
+ // e.g. "CSINTSY"
62592
+ startTime: text("start_time"),
62593
+ // start time string
62594
+ endTime: text("end_time"),
62595
+ // end time string
62593
62596
  isMajor: integer2("is_major", { mode: "boolean" }).notNull().default(false),
62594
62597
  // Slot tracking (local counter — NOT polled from Google Forms)
62595
62598
  maxSlots: integer2("max_slots").notNull().default(0),
@@ -62598,6 +62601,8 @@ var events = sqliteTable(
62598
62601
  // Google Form ID for Watch + reconciliation
62599
62602
  gformsUrl: text("gforms_url"),
62600
62603
  // informational link shown to students
62604
+ gformsEditorUrl: text("gforms_editor_url"),
62605
+ registrationClosesAt: integer2("registration_closes_at"),
62601
62606
  watchId: text("watch_id"),
62602
62607
  // stored after forms.watches.create
62603
62608
  watchExpiresAt: integer2("watch_expires_at"),
@@ -62608,8 +62613,6 @@ var events = sqliteTable(
62608
62613
  }).notNull().default("draft"),
62609
62614
  releaseAt: integer2("release_at"),
62610
62615
  // scheduled publish epoch
62611
- registrationOpensAt: integer2("registration_opens_at"),
62612
- registrationClosesAt: integer2("registration_closes_at"),
62613
62616
  // Reminder tracking
62614
62617
  reminder24hSent: integer2("reminder_24h_sent", { mode: "boolean" }).notNull().default(false),
62615
62618
  reminder1hSent: integer2("reminder_1h_sent", { mode: "boolean" }).notNull().default(false),
@@ -62625,6 +62628,7 @@ var events = sqliteTable(
62625
62628
  table.releaseAt
62626
62629
  ),
62627
62630
  themeIdx: index("idx_events_theme_id").on(table.themeId),
62631
+ organizationIdx: index("idx_events_organization_id").on(table.organizationId),
62628
62632
  slugIdx: index("idx_events_slug").on(table.slug)
62629
62633
  })
62630
62634
  );
@@ -62632,6 +62636,10 @@ var eventsRelations = relations(events, ({ one }) => ({
62632
62636
  theme: one(themes, {
62633
62637
  fields: [events.themeId],
62634
62638
  references: [themes.id]
62639
+ }),
62640
+ organization: one(organizations, {
62641
+ fields: [events.organizationId],
62642
+ references: [organizations.id]
62635
62643
  })
62636
62644
  }));
62637
62645
 
@@ -62652,7 +62660,6 @@ var faqs = sqliteTable("faqs", {
62652
62660
  category: text("category"),
62653
62661
  // optional grouping, e.g. "Registration"
62654
62662
  sortOrder: integer2("sort_order").notNull().default(0),
62655
- isActive: integer2("is_active", { mode: "boolean" }).notNull().default(true),
62656
62663
  createdAt: integer2("created_at").notNull().default(sql2`(unixepoch())`),
62657
62664
  updatedAt: integer2("updated_at").notNull().default(sql2`(unixepoch())`)
62658
62665
  });
@@ -62803,12 +62810,21 @@ function createAuth(env2) {
62803
62810
  */
62804
62811
  after: async (user) => {
62805
62812
  const [{ total }] = await db.select({ total: count() }).from(users);
62806
- await db.insert(users).values({
62813
+ const isFirstUser = total === 0;
62814
+ const base = {
62807
62815
  betterAuthId: user.id,
62808
62816
  email: user.email,
62809
62817
  name: user.name ?? user.email.split("@")[0],
62810
- role: total === 0 ? "super_admin" : "student"
62811
- }).onConflictDoNothing({ target: users.betterAuthId });
62818
+ role: isFirstUser ? "super_admin" : "student"
62819
+ };
62820
+ if (isFirstUser) {
62821
+ await db.insert(users).values(base).onConflictDoUpdate({
62822
+ target: users.betterAuthId,
62823
+ set: { role: "super_admin" }
62824
+ });
62825
+ } else {
62826
+ await db.insert(users).values(base).onConflictDoNothing({ target: users.betterAuthId });
62827
+ }
62812
62828
  }
62813
62829
  }
62814
62830
  }
@@ -62816,21 +62832,280 @@ function createAuth(env2) {
62816
62832
  });
62817
62833
  }
62818
62834
 
62835
+ // src/auth/middleware.ts
62836
+ var SESSION_KV_PREFIX = "auth:session:";
62837
+ var SESSION_KV_TTL = 3600;
62838
+ function extractRawToken(c) {
62839
+ const authHeader = c.req.header("Authorization");
62840
+ if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7);
62841
+ const cookie = c.req.header("Cookie") ?? "";
62842
+ const match2 = cookie.match(/(?:^|;\s*)better-auth\.session_token=([^;]+)/);
62843
+ return match2?.[1] ? decodeURIComponent(match2[1]) : void 0;
62844
+ }
62845
+ async function resolveUser(env2, betterAuthUserId, betterAuthUserEmail, betterAuthUserName, betterAuthEmailVerified) {
62846
+ const db = createDb(env2.DB);
62847
+ let dbUser = await db.query.users.findFirst({
62848
+ where: eq(users.betterAuthId, betterAuthUserId)
62849
+ });
62850
+ if (!dbUser) {
62851
+ const [created] = await db.insert(users).values({
62852
+ betterAuthId: betterAuthUserId,
62853
+ email: betterAuthUserEmail,
62854
+ name: betterAuthUserName ?? betterAuthUserEmail.split("@")[0]
62855
+ }).onConflictDoNothing({ target: users.betterAuthId }).returning();
62856
+ dbUser = created;
62857
+ }
62858
+ if (!dbUser) throw unauthorized("Failed to resolve user record");
62859
+ return {
62860
+ uid: betterAuthUserId,
62861
+ dbId: dbUser.id,
62862
+ role: dbUser.role,
62863
+ email: betterAuthUserEmail,
62864
+ name: betterAuthUserName ?? betterAuthUserEmail.split("@")[0],
62865
+ emailVerified: betterAuthEmailVerified
62866
+ };
62867
+ }
62868
+ var authMiddleware = createMiddleware(
62869
+ async (c, next) => {
62870
+ const rawToken = extractRawToken(c);
62871
+ if (rawToken) {
62872
+ const cached2 = await c.env.KV.get(
62873
+ `${SESSION_KV_PREFIX}${rawToken}`,
62874
+ "json"
62875
+ );
62876
+ if (cached2) {
62877
+ c.set("user", cached2);
62878
+ return next();
62879
+ }
62880
+ }
62881
+ const auth = createAuth(c.env);
62882
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
62883
+ if (!session?.user) {
62884
+ throw unauthorized("No valid session found");
62885
+ }
62886
+ if (!session.user.email.endsWith("@dlsu.edu.ph")) {
62887
+ throw domainRestricted();
62888
+ }
62889
+ const leapifyUser = await resolveUser(
62890
+ c.env,
62891
+ session.user.id,
62892
+ session.user.email,
62893
+ session.user.name,
62894
+ session.user.emailVerified
62895
+ );
62896
+ if (rawToken) {
62897
+ const sessionExpiresAt = new Date(session.session.expiresAt).getTime();
62898
+ const secondsRemaining = Math.floor((sessionExpiresAt - Date.now()) / 1e3);
62899
+ const kvTtl = Math.max(1, Math.min(secondsRemaining, SESSION_KV_TTL));
62900
+ await c.env.KV.put(
62901
+ `${SESSION_KV_PREFIX}${rawToken}`,
62902
+ JSON.stringify(leapifyUser),
62903
+ { expirationTtl: kvTtl }
62904
+ );
62905
+ }
62906
+ c.set("user", leapifyUser);
62907
+ return next();
62908
+ }
62909
+ );
62910
+ var optionalAuthMiddleware = createMiddleware(async (c, next) => {
62911
+ const rawToken = extractRawToken(c);
62912
+ if (!rawToken) {
62913
+ c.set("user", null);
62914
+ return next();
62915
+ }
62916
+ return authMiddleware(c, next);
62917
+ });
62918
+ var adminMiddleware = createMiddleware(
62919
+ async (c, next) => {
62920
+ const user = c.get("user");
62921
+ if (!user || !["admin", "super_admin"].includes(user.role)) {
62922
+ throw forbidden("Admin access required");
62923
+ }
62924
+ return next();
62925
+ }
62926
+ );
62927
+ var internalMiddleware = createMiddleware(async (c, next) => {
62928
+ const secret = c.req.header("X-Internal-Secret");
62929
+ if (!secret || secret !== c.env.INTERNAL_API_SECRET) {
62930
+ throw forbidden("Invalid internal secret");
62931
+ }
62932
+ return next();
62933
+ });
62934
+
62819
62935
  // src/routes/health.ts
62820
62936
  var healthRoute = new Hono2();
62821
- healthRoute.get("/", (c) => {
62822
- const hasSes = Boolean(c.env.SES_REGION) && Boolean(c.env.SES_ACCESS_KEY_ID) && Boolean(c.env.SES_SECRET_ACCESS_KEY);
62823
- const hasResend = Boolean(c.env.RESEND_API_KEY);
62937
+ async function probeResend(apiKey) {
62938
+ const start = Date.now();
62939
+ try {
62940
+ const res = await fetch("https://api.resend.com/domains", {
62941
+ headers: { Authorization: `Bearer ${apiKey}` }
62942
+ });
62943
+ return {
62944
+ configured: true,
62945
+ ok: res.ok,
62946
+ latencyMs: Date.now() - start,
62947
+ ...res.ok ? {} : { error: `HTTP ${res.status}` }
62948
+ };
62949
+ } catch (e) {
62950
+ return {
62951
+ configured: true,
62952
+ ok: false,
62953
+ latencyMs: Date.now() - start,
62954
+ error: String(e)
62955
+ };
62956
+ }
62957
+ }
62958
+ async function probeSes(region, accessKeyId, secretAccessKey) {
62959
+ const start = Date.now();
62960
+ try {
62961
+ if (!region || !accessKeyId || !secretAccessKey) {
62962
+ return {
62963
+ configured: false,
62964
+ ok: false,
62965
+ latencyMs: Date.now() - start,
62966
+ error: "Missing SES credentials"
62967
+ };
62968
+ }
62969
+ return {
62970
+ configured: true,
62971
+ ok: true,
62972
+ latencyMs: Date.now() - start
62973
+ };
62974
+ } catch (e) {
62975
+ return {
62976
+ configured: true,
62977
+ ok: false,
62978
+ latencyMs: Date.now() - start,
62979
+ error: String(e)
62980
+ };
62981
+ }
62982
+ }
62983
+ async function probeContentful(spaceId, accessToken, environment) {
62984
+ const start = Date.now();
62985
+ try {
62986
+ const res = await fetch(
62987
+ `https://cdn.contentful.com/spaces/${spaceId}/environments/${environment}/entries?limit=0`,
62988
+ { headers: { Authorization: `Bearer ${accessToken}` } }
62989
+ );
62990
+ return {
62991
+ configured: true,
62992
+ ok: res.ok,
62993
+ latencyMs: Date.now() - start,
62994
+ ...res.ok ? {} : { error: `HTTP ${res.status}` }
62995
+ };
62996
+ } catch (e) {
62997
+ return {
62998
+ configured: true,
62999
+ ok: false,
63000
+ latencyMs: Date.now() - start,
63001
+ error: String(e)
63002
+ };
63003
+ }
63004
+ }
63005
+ async function probeGForms(serviceAccountJson) {
63006
+ const start = Date.now();
63007
+ try {
63008
+ const creds = JSON.parse(serviceAccountJson);
63009
+ const now2 = Math.floor(Date.now() / 1e3);
63010
+ const claims = {
63011
+ iss: creds.client_email,
63012
+ scope: "https://www.googleapis.com/auth/forms.responses.readonly",
63013
+ aud: "https://oauth2.googleapis.com/token",
63014
+ iat: now2,
63015
+ exp: now2 + 3600
63016
+ };
63017
+ const header = { alg: "RS256", typ: "JWT" };
63018
+ const encode5 = (obj) => btoa(JSON.stringify(obj)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
63019
+ const signingInput = `${encode5(header)}.${encode5(claims)}`;
63020
+ const pemBody = creds.private_key.replace(/-----BEGIN PRIVATE KEY-----/, "").replace(/-----END PRIVATE KEY-----/, "").replace(/\s/g, "");
63021
+ const keyBytes = Uint8Array.from(atob(pemBody), (c) => c.charCodeAt(0));
63022
+ const privateKey = await crypto.subtle.importKey(
63023
+ "pkcs8",
63024
+ keyBytes,
63025
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
63026
+ false,
63027
+ ["sign"]
63028
+ );
63029
+ const signature = await crypto.subtle.sign(
63030
+ "RSASSA-PKCS1-v1_5",
63031
+ privateKey,
63032
+ new TextEncoder().encode(signingInput)
63033
+ );
63034
+ const sigB64 = btoa(String.fromCharCode(...new Uint8Array(signature))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
63035
+ const jwt2 = `${signingInput}.${sigB64}`;
63036
+ const res = await fetch("https://oauth2.googleapis.com/token", {
63037
+ method: "POST",
63038
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
63039
+ body: new URLSearchParams({
63040
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
63041
+ assertion: jwt2
63042
+ })
63043
+ });
63044
+ return {
63045
+ configured: true,
63046
+ ok: res.ok,
63047
+ latencyMs: Date.now() - start,
63048
+ ...res.ok ? {} : { error: `HTTP ${res.status}` }
63049
+ };
63050
+ } catch (e) {
63051
+ return {
63052
+ configured: true,
63053
+ ok: false,
63054
+ latencyMs: Date.now() - start,
63055
+ error: String(e)
63056
+ };
63057
+ }
63058
+ }
63059
+ healthRoute.get("/", async (c) => {
63060
+ const env2 = c.env;
63061
+ const hasSes = Boolean(env2.SES_REGION) && Boolean(env2.SES_ACCESS_KEY_ID) && Boolean(env2.SES_SECRET_ACCESS_KEY);
63062
+ const hasResend = Boolean(env2.RESEND_API_KEY);
63063
+ const hasContentful = Boolean(env2.CONTENTFUL_SPACE_ID) && Boolean(env2.CONTENTFUL_ACCESS_TOKEN);
63064
+ const hasGForms = Boolean(env2.GFORMS_SERVICE_ACCOUNT_JSON);
63065
+ const probes = [];
63066
+ if (hasSes) {
63067
+ probes.push(
63068
+ probeSes(env2.SES_REGION, env2.SES_ACCESS_KEY_ID, env2.SES_SECRET_ACCESS_KEY).then(
63069
+ (h) => ["ses", h]
63070
+ )
63071
+ );
63072
+ }
63073
+ if (hasResend) {
63074
+ probes.push(
63075
+ probeResend(env2.RESEND_API_KEY).then((h) => ["resend", h])
63076
+ );
63077
+ }
63078
+ if (hasContentful) {
63079
+ probes.push(
63080
+ probeContentful(
63081
+ env2.CONTENTFUL_SPACE_ID,
63082
+ env2.CONTENTFUL_ACCESS_TOKEN,
63083
+ env2.CONTENTFUL_ENVIRONMENT || "master"
63084
+ ).then((h) => ["contentful", h])
63085
+ );
63086
+ }
63087
+ if (hasGForms) {
63088
+ probes.push(
63089
+ probeGForms(env2.GFORMS_SERVICE_ACCOUNT_JSON).then(
63090
+ (h) => ["gforms", h]
63091
+ )
63092
+ );
63093
+ }
63094
+ const results = await Promise.all(probes);
63095
+ const services = {};
63096
+ for (const [name, health] of results) {
63097
+ services[name] = health;
63098
+ }
63099
+ const allOk = results.length === 0 || results.every(([, h]) => h.ok);
62824
63100
  return c.json({
62825
- status: "ok",
62826
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
62827
- providers: {
62828
- ses: hasSes,
62829
- resend: hasResend
63101
+ data: {
63102
+ status: allOk ? "OK" : "DEGRADED",
63103
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
63104
+ services
62830
63105
  }
62831
63106
  });
62832
63107
  });
62833
- healthRoute.post("/queue-burst", async (c) => {
63108
+ healthRoute.post("/queue-burst", authMiddleware, adminMiddleware, async (c) => {
62834
63109
  if (!c.env.EMAIL_QUEUE) {
62835
63110
  return c.json({ error: "Queue binding missing" }, 400);
62836
63111
  }
@@ -63310,106 +63585,189 @@ var GFormsService = class {
63310
63585
  return { expireTime: data.expireTime };
63311
63586
  }
63312
63587
  };
63313
-
63314
- // src/auth/middleware.ts
63315
- var SESSION_KV_PREFIX = "auth:session:";
63316
- var SESSION_KV_TTL = 3600;
63317
- function extractRawToken(c) {
63318
- const authHeader = c.req.header("Authorization");
63319
- if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7);
63320
- const cookie = c.req.header("Cookie") ?? "";
63321
- const match2 = cookie.match(/(?:^|;\s*)better-auth\.session_token=([^;]+)/);
63322
- return match2?.[1] ? decodeURIComponent(match2[1]) : void 0;
63323
- }
63324
- async function resolveUser(env2, betterAuthUserId, betterAuthUserEmail, betterAuthUserName, betterAuthEmailVerified) {
63325
- const db = createDb(env2.DB);
63326
- let dbUser = await db.query.users.findFirst({
63327
- where: eq(users.betterAuthId, betterAuthUserId)
63328
- });
63329
- if (!dbUser) {
63330
- const [created] = await db.insert(users).values({
63331
- betterAuthId: betterAuthUserId,
63332
- email: betterAuthUserEmail,
63333
- name: betterAuthUserName ?? betterAuthUserEmail.split("@")[0]
63334
- }).onConflictDoNothing({ target: users.betterAuthId }).returning();
63335
- dbUser = created;
63336
- }
63337
- if (!dbUser) throw unauthorized("Failed to resolve user record");
63338
- return {
63339
- uid: betterAuthUserId,
63340
- dbId: dbUser.id,
63341
- role: dbUser.role,
63342
- email: betterAuthUserEmail,
63343
- name: betterAuthUserName ?? betterAuthUserEmail.split("@")[0],
63344
- emailVerified: betterAuthEmailVerified
63345
- };
63346
- }
63347
- var authMiddleware = createMiddleware(
63348
- async (c, next) => {
63349
- const rawToken = extractRawToken(c);
63350
- if (rawToken) {
63351
- const cached2 = await c.env.KV.get(
63352
- `${SESSION_KV_PREFIX}${rawToken}`,
63353
- "json"
63354
- );
63355
- if (cached2) {
63356
- c.set("user", cached2);
63357
- return next();
63588
+ var ContentfulManagement = class {
63589
+ client;
63590
+ constructor(spaceId, token, environment = "master") {
63591
+ this.client = createClient(
63592
+ { accessToken: token },
63593
+ {
63594
+ type: "plain",
63595
+ defaults: { spaceId, environmentId: environment }
63358
63596
  }
63597
+ );
63598
+ }
63599
+ static isConfigured(spaceId, token) {
63600
+ return !!(spaceId && token);
63601
+ }
63602
+ // ─── Content Types ───────────────────────────────────────────────────────
63603
+ async getContentType(contentTypeId) {
63604
+ try {
63605
+ return await this.client.contentType.get({ contentTypeId });
63606
+ } catch (err) {
63607
+ if (err?.status === 404) return null;
63608
+ console.warn(`[Contentful] getContentType(${contentTypeId}) error:`, err?.message ?? err);
63609
+ return null;
63359
63610
  }
63360
- const auth = createAuth(c.env);
63361
- const session = await auth.api.getSession({ headers: c.req.raw.headers });
63362
- if (!session?.user) {
63363
- throw unauthorized("No valid session found");
63611
+ }
63612
+ async ensureContentType(contentTypeId, name, fields) {
63613
+ const existing = await this.getContentType(contentTypeId);
63614
+ if (!existing) {
63615
+ console.log(`[Contentful] Creating content type ${contentTypeId}...`);
63616
+ try {
63617
+ const created = await this.client.contentType.createWithId(
63618
+ { contentTypeId },
63619
+ { name, fields, displayField: fields[0]?.id }
63620
+ );
63621
+ console.log(`[Contentful] Created ${contentTypeId} v${created.sys.version}`);
63622
+ let version4 = created.sys.version;
63623
+ for (let attempt = 0; attempt < 5; attempt++) {
63624
+ await new Promise((r) => setTimeout(r, 3e3));
63625
+ try {
63626
+ const saved = await this.client.contentType.update(
63627
+ { contentTypeId },
63628
+ { ...created, name, fields, displayField: fields[0]?.id, sys: { ...created.sys, version: version4 } }
63629
+ );
63630
+ console.log(`[Contentful] Saved ${contentTypeId} \u2192 v${saved.sys.version}`);
63631
+ await this.client.contentType.publish(
63632
+ { contentTypeId },
63633
+ saved
63634
+ );
63635
+ console.log(`[Contentful] Published ${contentTypeId}`);
63636
+ return;
63637
+ } catch (err) {
63638
+ console.warn(`[Contentful] Attempt ${attempt + 1} to publish ${contentTypeId} failed:`, err?.message ?? err);
63639
+ const refetched = await this.getContentType(contentTypeId);
63640
+ if (refetched) version4 = refetched.sys.version ?? version4;
63641
+ }
63642
+ }
63643
+ console.warn(`[Contentful] Could not publish ${contentTypeId} after retries`);
63644
+ } catch (err) {
63645
+ if (err?.status === 409) {
63646
+ console.log(`[Contentful] ${contentTypeId} already exists (409), attempting to publish...`);
63647
+ await this.tryPublishExisting(contentTypeId, name, fields);
63648
+ return;
63649
+ }
63650
+ console.warn(`[Contentful] Failed to create ${contentTypeId}:`, err?.message ?? err);
63651
+ }
63652
+ return;
63364
63653
  }
63365
- if (!session.user.email.endsWith("@dlsu.edu.ph")) {
63366
- throw domainRestricted();
63654
+ if (!existing.sys.publishedVersion || existing.sys.publishedVersion < (existing.sys.version ?? 0)) {
63655
+ for (let attempt = 0; attempt < 3; attempt++) {
63656
+ try {
63657
+ await this.client.contentType.publish({ contentTypeId }, existing);
63658
+ console.log(`[Contentful] Published ${contentTypeId}`);
63659
+ return;
63660
+ } catch {
63661
+ await new Promise((r) => setTimeout(r, 2e3 * (attempt + 1)));
63662
+ }
63663
+ }
63367
63664
  }
63368
- const leapifyUser = await resolveUser(
63369
- c.env,
63370
- session.user.id,
63371
- session.user.email,
63372
- session.user.name,
63373
- session.user.emailVerified
63374
- );
63375
- if (rawToken) {
63376
- const sessionExpiresAt = new Date(session.session.expiresAt).getTime();
63377
- const secondsRemaining = Math.floor((sessionExpiresAt - Date.now()) / 1e3);
63378
- const kvTtl = Math.max(1, Math.min(secondsRemaining, SESSION_KV_TTL));
63379
- await c.env.KV.put(
63380
- `${SESSION_KV_PREFIX}${rawToken}`,
63381
- JSON.stringify(leapifyUser),
63382
- { expirationTtl: kvTtl }
63665
+ const existingFieldIds = existing.fields.map((f) => f.id).sort().join(",");
63666
+ const newFieldIds = fields.map((f) => f.id).sort().join(",");
63667
+ if (existingFieldIds !== newFieldIds || !existing.displayField) {
63668
+ const updated = await this.client.contentType.update(
63669
+ { contentTypeId },
63670
+ { ...existing, name, fields, displayField: fields[0]?.id }
63383
63671
  );
63672
+ await this.client.contentType.publish({ contentTypeId }, updated);
63673
+ console.log(`[Contentful] Updated + published ${contentTypeId}`);
63384
63674
  }
63385
- c.set("user", leapifyUser);
63386
- return next();
63387
63675
  }
63388
- );
63389
- var optionalAuthMiddleware = createMiddleware(async (c, next) => {
63390
- const rawToken = extractRawToken(c);
63391
- if (!rawToken) {
63392
- c.set("user", null);
63393
- return next();
63676
+ async tryPublishExisting(contentTypeId, name, fields) {
63677
+ for (let attempt = 0; attempt < 5; attempt++) {
63678
+ await new Promise((r) => setTimeout(r, 3e3));
63679
+ const existing = await this.getContentType(contentTypeId);
63680
+ if (existing) {
63681
+ if (!existing.sys.publishedVersion || existing.sys.publishedVersion < (existing.sys.version ?? 0)) {
63682
+ try {
63683
+ const saved = await this.client.contentType.update(
63684
+ { contentTypeId },
63685
+ { ...existing, name, fields, displayField: fields[0]?.id }
63686
+ );
63687
+ await this.client.contentType.publish({ contentTypeId }, saved);
63688
+ console.log(`[Contentful] Published existing ${contentTypeId}`);
63689
+ return;
63690
+ } catch (err) {
63691
+ console.warn(`[Contentful] Attempt ${attempt + 1} to publish existing ${contentTypeId}:`, err?.message ?? err);
63692
+ }
63693
+ } else {
63694
+ console.log(`[Contentful] ${contentTypeId} already published`);
63695
+ return;
63696
+ }
63697
+ }
63698
+ }
63699
+ console.warn(`[Contentful] Could not publish existing ${contentTypeId} after retries`);
63394
63700
  }
63395
- return authMiddleware(c, next);
63396
- });
63397
- var adminMiddleware = createMiddleware(
63398
- async (c, next) => {
63399
- const user = c.get("user");
63400
- if (!user || !["admin", "super_admin"].includes(user.role)) {
63401
- throw forbidden("Admin access required");
63701
+ // ─── Entries ─────────────────────────────────────────────────────────────
63702
+ async getEntry(entryId) {
63703
+ try {
63704
+ return await this.client.entry.get({ entryId });
63705
+ } catch (err) {
63706
+ if (err?.status === 404) return null;
63707
+ return null;
63402
63708
  }
63403
- return next();
63404
63709
  }
63405
- );
63406
- var internalMiddleware = createMiddleware(async (c, next) => {
63407
- const secret = c.req.header("X-Internal-Secret");
63408
- if (!secret || secret !== c.env.INTERNAL_API_SECRET) {
63409
- throw forbidden("Invalid internal secret");
63710
+ async upsertEntry(contentTypeId, entryId, fields) {
63711
+ const existing = await this.getEntry(entryId);
63712
+ let entry;
63713
+ if (existing) {
63714
+ entry = await this.client.entry.update(
63715
+ { entryId },
63716
+ { ...existing, fields }
63717
+ );
63718
+ } else {
63719
+ entry = await this.client.entry.createWithId(
63720
+ { entryId, contentTypeId },
63721
+ { fields }
63722
+ );
63723
+ }
63724
+ return this.client.entry.publish({ entryId }, entry);
63725
+ }
63726
+ async unpublishEntry(entryId) {
63727
+ const entry = await this.getEntry(entryId);
63728
+ if (!entry) return null;
63729
+ return this.client.entry.unpublish({ entryId }, entry);
63730
+ }
63731
+ async deleteEntry(entryId) {
63732
+ await this.client.entry.delete({ entryId });
63733
+ }
63734
+ // ─── Asset Uploads ────────────────────────────────────────────────────────
63735
+ async uploadFile(_fileName, data, _contentType) {
63736
+ const upload = await this.client.upload.create({}, { file: data });
63737
+ return upload.sys.id;
63738
+ }
63739
+ async createAssetFromUpload(uploadId, title, fileName, contentType) {
63740
+ const asset = await this.client.asset.create({}, {
63741
+ fields: {
63742
+ title: { "en-US": title },
63743
+ file: {
63744
+ "en-US": {
63745
+ contentType,
63746
+ fileName,
63747
+ uploadFrom: { sys: { type: "Link", linkType: "Upload", id: uploadId } }
63748
+ }
63749
+ }
63750
+ }
63751
+ });
63752
+ const processed = await this.client.asset.processForLocale({}, asset, "en-US");
63753
+ const published = await this.client.asset.publish(
63754
+ { assetId: processed.sys.id },
63755
+ processed
63756
+ );
63757
+ const url2 = published.fields?.file?.["en-US"]?.url ?? "";
63758
+ return { id: published.sys.id, url: url2 };
63410
63759
  }
63411
- return next();
63412
- });
63760
+ // ─── Helpers ─────────────────────────────────────────────────────────────
63761
+ static locale(value) {
63762
+ return { "en-US": value };
63763
+ }
63764
+ static entryRef(entryId) {
63765
+ return { "en-US": { sys: { type: "Link", linkType: "Entry", id: entryId } } };
63766
+ }
63767
+ static assetRef(assetId) {
63768
+ return { "en-US": { sys: { type: "Link", linkType: "Asset", id: assetId } } };
63769
+ }
63770
+ };
63413
63771
 
63414
63772
  // src/lib/middleware/rate-limit.ts
63415
63773
  function createRateLimitMiddleware(config3) {
@@ -63465,25 +63823,87 @@ var adminEventsRateLimit = createRateLimitMiddleware({
63465
63823
  var EVENTS_LIST_KV_KEY = "events:list";
63466
63824
  var EVENTS_ETAG_KV_KEY = "events:etag";
63467
63825
  var EVENTS_LIST_TTL = 300;
63826
+ var CF_EVENT_CT = "event";
63827
+ async function pushEventToContentful(env2, event) {
63828
+ if (!ContentfulManagement.isConfigured(env2.CONTENTFUL_SPACE_ID, env2.CONTENTFUL_MANAGEMENT_TOKEN)) return;
63829
+ const mgmt = new ContentfulManagement(
63830
+ env2.CONTENTFUL_SPACE_ID,
63831
+ env2.CONTENTFUL_MANAGEMENT_TOKEN,
63832
+ env2.CONTENTFUL_ENVIRONMENT
63833
+ );
63834
+ try {
63835
+ const fields = {
63836
+ title: ContentfulManagement.locale(event.title),
63837
+ slug: ContentfulManagement.locale(event.slug),
63838
+ isMajor: ContentfulManagement.locale(event.isMajor),
63839
+ maxSlots: ContentfulManagement.locale(event.maxSlots)
63840
+ };
63841
+ if (event.themeId) fields.theme = ContentfulManagement.entryRef(event.themeId);
63842
+ if (event.organizationId) fields.organization = ContentfulManagement.entryRef(event.organizationId);
63843
+ if (event.venue) fields.venue = ContentfulManagement.locale(event.venue);
63844
+ if (event.dateTime) {
63845
+ const parsed = new Date(event.dateTime);
63846
+ fields.date = ContentfulManagement.locale(
63847
+ Number.isNaN(parsed.getTime()) ? event.dateTime : parsed.toISOString()
63848
+ );
63849
+ }
63850
+ if (event.price) fields.price = ContentfulManagement.locale(event.price);
63851
+ if (event.classCode) fields.classCode = ContentfulManagement.locale(event.classCode);
63852
+ if (event.startTime) fields.startTime = ContentfulManagement.locale(event.startTime);
63853
+ if (event.endTime) fields.endTime = ContentfulManagement.locale(event.endTime);
63854
+ if (event.gformsUrl) fields.gformsUrl = ContentfulManagement.locale(event.gformsUrl);
63855
+ if (event.gformsEditorUrl) fields.gformsEditorUrl = ContentfulManagement.locale(event.gformsEditorUrl);
63856
+ if (event.registrationClosesAt) {
63857
+ fields.registrationClosesAt = ContentfulManagement.locale(
63858
+ new Date(event.registrationClosesAt * 1e3).toISOString()
63859
+ );
63860
+ }
63861
+ if (event.backgroundImageUrl && env2.FILES) {
63862
+ try {
63863
+ const imageKey = new URL(event.backgroundImageUrl).pathname.replace(/^\/api\/uploads\/images\//, "");
63864
+ console.log(`[Contentful] Attempting image sync for event ${event.id}, key: ${imageKey}`);
63865
+ const object2 = await env2.FILES.get(imageKey);
63866
+ if (object2) {
63867
+ const data = await object2.arrayBuffer();
63868
+ const contentType = object2.httpMetadata?.contentType || "image/jpeg";
63869
+ const fileName = imageKey.split("/").pop() || "image.jpg";
63870
+ const uploadId = await mgmt.uploadFile(fileName, data, contentType);
63871
+ const asset = await mgmt.createAssetFromUpload(uploadId, event.title, fileName, contentType);
63872
+ fields.image = ContentfulManagement.assetRef(asset.id);
63873
+ console.log(`[Contentful] Image synced for event ${event.id}, asset: ${asset.id}`);
63874
+ } else {
63875
+ console.warn(`[Contentful] Image not found in R2 for event ${event.id}, key: ${imageKey}`);
63876
+ }
63877
+ } catch (err) {
63878
+ console.warn(`[Contentful] Failed to upload image for event ${event.id}:`, err);
63879
+ }
63880
+ } else if (event.backgroundImageUrl && !env2.FILES) {
63881
+ console.warn(`[Contentful] FILES binding not available, skipping image sync for event ${event.id}`);
63882
+ }
63883
+ await mgmt.upsertEntry(CF_EVENT_CT, event.id, fields);
63884
+ } catch (err) {
63885
+ console.warn(`[Contentful] Failed to sync event ${event.id}:`, err);
63886
+ }
63887
+ }
63468
63888
  var createEventSchema = external_exports.object({
63469
63889
  themeId: external_exports.string().min(1),
63890
+ organizationId: external_exports.string().optional(),
63470
63891
  title: external_exports.string().min(1),
63471
- org: external_exports.string().optional(),
63892
+ description: external_exports.string().optional(),
63472
63893
  venue: external_exports.string().optional(),
63473
63894
  dateTime: external_exports.string().optional(),
63474
- startsAt: external_exports.number().optional(),
63475
- endsAt: external_exports.number().optional(),
63476
63895
  price: external_exports.string().optional(),
63477
- backgroundColor: external_exports.string().optional(),
63478
63896
  backgroundImageUrl: external_exports.string().url().optional(),
63479
- subtheme: external_exports.string().optional(),
63897
+ classCode: external_exports.string().optional(),
63898
+ startTime: external_exports.string().optional(),
63899
+ endTime: external_exports.string().optional(),
63900
+ registrationClosesAt: external_exports.number().optional(),
63480
63901
  isMajor: external_exports.boolean().default(false),
63481
63902
  maxSlots: external_exports.number().int().min(0).default(0),
63482
63903
  gformsId: external_exports.string().optional(),
63483
63904
  gformsUrl: external_exports.string().url().optional(),
63905
+ gformsEditorUrl: external_exports.string().url().optional(),
63484
63906
  releaseAt: external_exports.number().optional(),
63485
- registrationOpensAt: external_exports.number().optional(),
63486
- registrationClosesAt: external_exports.number().optional(),
63487
63907
  contentfulEntryId: external_exports.string().optional(),
63488
63908
  status: external_exports.enum(["draft", "queued", "published"]).default("draft")
63489
63909
  });
@@ -63491,6 +63911,42 @@ var eventsRoute = new Hono2();
63491
63911
  function generateSlug(title) {
63492
63912
  return title.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
63493
63913
  }
63914
+ eventsRoute.get("/admin", authMiddleware, adminMiddleware, async (c) => {
63915
+ const db = createDb(c.env.DB);
63916
+ const data = await db.query.events.findMany({
63917
+ with: { theme: true, organization: true },
63918
+ orderBy: (e, { desc: desc2 }) => [desc2(e.createdAt)]
63919
+ });
63920
+ return c.json({ data });
63921
+ });
63922
+ eventsRoute.post("/admin/publish", authMiddleware, adminMiddleware, async (c) => {
63923
+ const body = await c.req.json();
63924
+ const db = createDb(c.env.DB);
63925
+ const cache3 = new CacheService(c.env.KV);
63926
+ if (!body.ids?.length) {
63927
+ return c.json({ error: "ids are required" }, 400);
63928
+ }
63929
+ if (body.releaseAt) {
63930
+ await db.update(events).set({ releaseAt: body.releaseAt, status: "queued" }).where(
63931
+ sql2`${events.id} IN (${sql2.join(
63932
+ body.ids.map((id) => sql2`${id}`),
63933
+ sql2`, `
63934
+ )})`
63935
+ );
63936
+ } else {
63937
+ await db.update(events).set({ status: "published", publishedAt: sql2`(unixepoch())` }).where(
63938
+ sql2`${events.id} IN (${sql2.join(
63939
+ body.ids.map((id) => sql2`${id}`),
63940
+ sql2`, `
63941
+ )})`
63942
+ );
63943
+ }
63944
+ await Promise.all([
63945
+ cache3.del(EVENTS_LIST_KV_KEY),
63946
+ cache3.del(EVENTS_ETAG_KV_KEY)
63947
+ ]);
63948
+ return c.json({ data: { updated: body.ids.length } });
63949
+ });
63494
63950
  eventsRoute.get("/", eventsListRateLimit, async (c) => {
63495
63951
  const db = createDb(c.env.DB);
63496
63952
  const cache3 = new CacheService(c.env.KV);
@@ -63509,28 +63965,28 @@ eventsRoute.get("/", eventsListRateLimit, async (c) => {
63509
63965
  () => db.query.events.findMany({
63510
63966
  where: eq(events.status, "published"),
63511
63967
  with: {
63512
- theme: true
63968
+ theme: true,
63969
+ organization: true
63513
63970
  },
63514
63971
  columns: {
63515
63972
  id: true,
63516
63973
  slug: true,
63517
63974
  themeId: true,
63975
+ organizationId: true,
63518
63976
  title: true,
63519
- org: true,
63520
63977
  venue: true,
63521
63978
  dateTime: true,
63522
- startsAt: true,
63523
- endsAt: true,
63524
63979
  price: true,
63525
- backgroundColor: true,
63526
63980
  backgroundImageUrl: true,
63527
- subtheme: true,
63981
+ classCode: true,
63982
+ startTime: true,
63983
+ endTime: true,
63984
+ registrationClosesAt: true,
63528
63985
  isMajor: true,
63529
63986
  maxSlots: true,
63530
63987
  registeredSlots: true,
63531
63988
  gformsUrl: true,
63532
- registrationOpensAt: true,
63533
- registrationClosesAt: true,
63989
+ gformsEditorUrl: true,
63534
63990
  publishedAt: true
63535
63991
  }
63536
63992
  }),
@@ -63600,6 +64056,7 @@ eventsRoute.post(
63600
64056
  cache3.del(EVENTS_LIST_KV_KEY),
63601
64057
  cache3.del(EVENTS_ETAG_KV_KEY)
63602
64058
  ]);
64059
+ c.executionCtx.waitUntil(pushEventToContentful(c.env, created));
63603
64060
  return c.json({ data: created }, 201);
63604
64061
  }
63605
64062
  );
@@ -63618,11 +64075,57 @@ eventsRoute.patch("/:slug", authMiddleware, adminMiddleware, async (c) => {
63618
64075
  cache3.del(EVENTS_LIST_KV_KEY),
63619
64076
  cache3.del(EVENTS_ETAG_KV_KEY)
63620
64077
  ]);
64078
+ c.executionCtx.waitUntil(pushEventToContentful(c.env, updated));
63621
64079
  return c.json({ data: updated });
63622
64080
  });
64081
+ eventsRoute.delete("/:slug", authMiddleware, adminMiddleware, async (c) => {
64082
+ const { slug } = c.req.param();
64083
+ const db = createDb(c.env.DB);
64084
+ const cache3 = new CacheService(c.env.KV);
64085
+ const [deleted] = await db.delete(events).where(eq(events.slug, slug)).returning();
64086
+ if (!deleted) throw notFound("Event");
64087
+ await Promise.all([
64088
+ cache3.del(EVENTS_LIST_KV_KEY),
64089
+ cache3.del(EVENTS_ETAG_KV_KEY)
64090
+ ]);
64091
+ return c.body(null, 204);
64092
+ });
63623
64093
 
63624
64094
  // src/routes/users.ts
64095
+ var VALID_ROLES = ["student", "admin", "super_admin"];
63625
64096
  var usersRoute = new Hono2();
64097
+ usersRoute.get("/", authMiddleware, adminMiddleware, async (c) => {
64098
+ const db = createDb(c.env.DB);
64099
+ const data = await db.select().from(users);
64100
+ return c.json({ data });
64101
+ });
64102
+ usersRoute.patch("/:id/role", authMiddleware, adminMiddleware, async (c) => {
64103
+ const { id } = c.req.param();
64104
+ const { role } = await c.req.json();
64105
+ if (!role || !VALID_ROLES.includes(role)) {
64106
+ throw badRequest("Role must be 'student', 'admin', or 'super_admin'.");
64107
+ }
64108
+ const db = createDb(c.env.DB);
64109
+ const [updated] = await db.update(users).set({ role }).where(eq(users.id, id)).returning();
64110
+ if (!updated) throw notFound("User");
64111
+ return c.json({ data: updated });
64112
+ });
64113
+ usersRoute.post("/by-email", authMiddleware, adminMiddleware, async (c) => {
64114
+ const { email: email3, role } = await c.req.json();
64115
+ if (!email3 || !role || !VALID_ROLES.includes(role)) {
64116
+ throw badRequest("Email and valid role ('student', 'admin', 'super_admin') are required.");
64117
+ }
64118
+ const db = createDb(c.env.DB);
64119
+ const existing = await db.query.users.findFirst({
64120
+ where: eq(users.email, email3)
64121
+ });
64122
+ if (existing) {
64123
+ const [updated] = await db.update(users).set({ role }).where(eq(users.email, email3)).returning();
64124
+ return c.json({ data: updated });
64125
+ }
64126
+ const [created] = await db.insert(users).values({ betterAuthId: `pending:${email3}`, email: email3, name: email3.split("@")[0], role }).returning();
64127
+ return c.json({ data: created }, 201);
64128
+ });
63626
64129
  usersRoute.get("/me", optionalAuthMiddleware, async (c) => {
63627
64130
  const user = c.get("user");
63628
64131
  if (!user) return c.json({ data: null });
@@ -63631,7 +64134,11 @@ usersRoute.get("/me", optionalAuthMiddleware, async (c) => {
63631
64134
  where: eq(users.id, user.dbId)
63632
64135
  });
63633
64136
  if (!profile) return c.json({ data: null });
63634
- return c.json({ data: profile });
64137
+ const auth = await db.query.authUser.findFirst({
64138
+ where: eq(authUser.id, profile.betterAuthId),
64139
+ columns: { image: true }
64140
+ });
64141
+ return c.json({ data: { ...profile, image: auth?.image ?? null } });
63635
64142
  });
63636
64143
  usersRoute.get("/me/bookmarks", optionalAuthMiddleware, async (c) => {
63637
64144
  const user = c.get("user");
@@ -63670,297 +64177,83 @@ usersRoute.delete("/me/bookmarks/:eventId", authMiddleware, async (c) => {
63670
64177
  return c.json({ data: { bookmarked: false } });
63671
64178
  });
63672
64179
 
63673
- // src/services/contentful-management.ts
63674
- var CONTENTFUL_MGMT = "https://api.contentful.com";
63675
- var ContentfulManagement = class {
63676
- spaceId;
63677
- token;
63678
- environment;
63679
- constructor(spaceId, token, environment = "master") {
63680
- this.spaceId = spaceId;
63681
- this.token = token;
63682
- this.environment = environment;
63683
- }
63684
- static isConfigured(spaceId, token) {
63685
- return !!(spaceId && token);
63686
- }
63687
- // ─── Content Types ───────────────────────────────────────────────────────
63688
- /**
63689
- * Get a content type by ID. Returns null if not found.
63690
- */
63691
- async getContentType(contentTypeId) {
63692
- const res = await this.fetch(`/content_types/${contentTypeId}`);
63693
- if (res.status === 404) return null;
63694
- if (!res.ok) throw new Error(`Failed to get content type: ${res.status} ${await res.text()}`);
63695
- return res.json();
63696
- }
63697
- /**
63698
- * Create a content type. Does NOT publish it — call publishContentType() after.
63699
- */
63700
- async createContentType(contentTypeId, name, fields) {
63701
- const res = await this.fetch(`/content_types`, {
63702
- method: "POST",
63703
- headers: { "Content-Type": "application/json", ...this.authHeader() },
63704
- body: JSON.stringify({
63705
- sys: { id: contentTypeId, type: "ContentType" },
63706
- name,
63707
- fields
63708
- })
63709
- });
63710
- if (!res.ok) throw new Error(`Failed to create content type: ${res.status} ${await res.text()}`);
63711
- const ct = await res.json();
63712
- console.log(`[Contentful] Created content type ${contentTypeId}: version=${ct.sys.version}`);
63713
- return ct;
63714
- }
63715
- /**
63716
- * Update a content type. Must republish after update.
63717
- */
63718
- async updateContentType(contentTypeId, name, fields, version4) {
63719
- const res = await this.fetch(`/content_types/${contentTypeId}`, {
63720
- method: "PUT",
63721
- headers: {
63722
- "Content-Type": "application/json",
63723
- ...this.authHeader(),
63724
- "X-Contentful-Version": String(version4)
63725
- },
63726
- body: JSON.stringify({
63727
- sys: { id: contentTypeId, type: "ContentType" },
63728
- name,
63729
- fields
63730
- })
63731
- });
63732
- if (!res.ok) throw new Error(`Failed to update content type: ${res.status} ${await res.text()}`);
63733
- return res.json();
63734
- }
63735
- /**
63736
- * Publish a content type (makes it available for entries).
63737
- */
63738
- async publishContentType(contentTypeId, version4) {
63739
- console.log(`[Contentful] Publishing content type ${contentTypeId} with version ${version4}`);
63740
- const res = await this.fetch(`/content_types/${contentTypeId}/published`, {
63741
- method: "PUT",
63742
- headers: {
63743
- "Content-Type": "application/vnd.contentful.management.v1+json",
63744
- ...this.authHeader(),
63745
- "X-Contentful-Version": String(version4)
63746
- }
63747
- });
63748
- if (!res.ok) {
63749
- const body = await res.text();
63750
- console.error(`[Contentful] Publish failed: ${res.status} ${body}`);
63751
- throw new Error(`Failed to publish content type: ${res.status} ${body}`);
63752
- }
63753
- return res.json();
63754
- }
63755
- // ─── Entries ─────────────────────────────────────────────────────────────
63756
- /**
63757
- * Get an entry by ID. Returns null if not found.
63758
- */
63759
- async getEntry(entryId) {
63760
- const res = await this.fetch(`/entries/${entryId}`);
63761
- if (res.status === 404) return null;
63762
- if (!res.ok) throw new Error(`Failed to get entry: ${res.status} ${await res.text()}`);
63763
- return res.json();
63764
- }
63765
- /**
63766
- * Create a new entry. Does NOT publish — call publishEntry() after.
63767
- * Fields must be locale-wrapped: { "en-US": value }
63768
- */
63769
- async createEntry(contentTypeId, entryId, fields) {
63770
- const res = await this.fetch(`/entries?content_type=${contentTypeId}`, {
63771
- method: "PUT",
63772
- headers: {
63773
- "Content-Type": "application/vnd.contentful.management.v1+json",
63774
- ...this.authHeader(),
63775
- "X-Contentful-Content-Type": contentTypeId
63776
- },
63777
- body: JSON.stringify({
63778
- sys: { id: entryId, type: "Entry" },
63779
- fields
63780
- })
63781
- });
63782
- if (!res.ok) throw new Error(`Failed to create entry: ${res.status} ${await res.text()}`);
63783
- return res.json();
63784
- }
63785
- /**
63786
- * Update an existing entry. Must republish after update.
63787
- */
63788
- async updateEntry(entryId, fields, version4) {
63789
- const res = await this.fetch(`/entries/${entryId}`, {
63790
- method: "PUT",
63791
- headers: {
63792
- "Content-Type": "application/vnd.contentful.management.v1+json",
63793
- ...this.authHeader(),
63794
- "X-Contentful-Version": String(version4)
63795
- },
63796
- body: JSON.stringify({ fields })
63797
- });
63798
- if (!res.ok) throw new Error(`Failed to update entry: ${res.status} ${await res.text()}`);
63799
- return res.json();
63800
- }
63801
- /**
63802
- * Publish an entry (makes it available via Delivery API).
63803
- */
63804
- async publishEntry(entryId, version4) {
63805
- const res = await this.fetch(`/entries/${entryId}/published`, {
63806
- method: "PUT",
63807
- headers: {
63808
- ...this.authHeader(),
63809
- "X-Contentful-Version": String(version4)
63810
- }
63811
- });
63812
- if (!res.ok) throw new Error(`Failed to publish entry: ${res.status} ${await res.text()}`);
63813
- return res.json();
63814
- }
63815
- /**
63816
- * Upsert an entry: create if missing, update if exists, then publish.
63817
- */
63818
- async upsertEntry(contentTypeId, entryId, fields) {
63819
- let existing = null;
63820
- try {
63821
- existing = await Promise.race([
63822
- this.getEntry(entryId),
63823
- new Promise((_, reject) => setTimeout(() => reject(new Error("getEntry timeout")), 5e3))
63824
- ]);
63825
- } catch (err) {
63826
- console.warn(`[Contentful] getEntry failed (will try create): ${err}`);
63827
- }
63828
- console.log(`[Contentful] upsertEntry: existing=${existing ? "yes" : "no"}`);
63829
- let entry;
63830
- if (existing) {
63831
- entry = await this.updateEntry(entryId, fields, existing.sys.version ?? 1);
63832
- } else {
63833
- console.log(`[Contentful] upsertEntry: creating new entry...`);
63834
- entry = await this.createEntry(contentTypeId, entryId, fields);
63835
- }
63836
- console.log(`[Contentful] upsertEntry: publishing (version=${entry.sys.version})`);
63837
- const published = await this.publishEntry(entry.sys.id, entry.sys.version ?? 1);
63838
- console.log(`[Contentful] upsertEntry: published successfully`);
63839
- return published;
63840
- }
63841
- // ─── Content type setup ──────────────────────────────────────────────────
63842
- /**
63843
- * Ensure a content type exists and is published with the given fields.
63844
- * Creates it if missing, updates if fields changed.
63845
- */
63846
- async ensureContentType(contentTypeId, name, fields) {
63847
- const existing = await this.getContentType(contentTypeId);
63848
- if (!existing) {
63849
- const created = await this.createContentType(contentTypeId, name, fields);
63850
- const version4 = created.sys.version ?? 1;
63851
- console.log(`[Contentful] Created content type ${contentTypeId}, attempting publish with version ${version4}`);
63852
- try {
63853
- await this.publishContentType(contentTypeId, version4);
63854
- console.log(`[Contentful] Created and published content type: ${contentTypeId}`);
63855
- } catch (err) {
63856
- console.warn(`[Contentful] Created content type ${contentTypeId} but publish failed (entries will still work): ${err}`);
63857
- }
63858
- return;
63859
- }
63860
- const existingFieldIds = existing.fields.map((f) => f.id).sort().join(",");
63861
- const newFieldIds = fields.map((f) => f.id).sort().join(",");
63862
- if (existingFieldIds !== newFieldIds) {
63863
- const updated = await this.updateContentType(contentTypeId, name, fields, existing.sys.version ?? 1);
63864
- const fetched = await this.getContentType(contentTypeId);
63865
- const version4 = fetched?.sys.version ?? updated.sys.version ?? 1;
63866
- try {
63867
- await this.publishContentType(contentTypeId, version4);
63868
- console.log(`[Contentful] Updated and published content type: ${contentTypeId}`);
63869
- } catch (err) {
63870
- console.warn(`[Contentful] Updated content type ${contentTypeId} but publish failed: ${err}`);
63871
- }
63872
- } else if (!existing.sys.publishedVersion || existing.sys.publishedVersion < (existing.sys.version ?? 0)) {
63873
- try {
63874
- await this.publishContentType(contentTypeId, existing.sys.version ?? 1);
63875
- console.log(`[Contentful] Published content type: ${contentTypeId}`);
63876
- } catch (err) {
63877
- console.warn(`[Contentful] Publish failed for ${contentTypeId}: ${err}`);
63878
- }
63879
- }
63880
- }
63881
- // ─── Helpers ─────────────────────────────────────────────────────────────
63882
- /**
63883
- * Locale-wrap a value for Contentful fields.
63884
- */
63885
- static locale(value) {
63886
- return { "en-US": value };
63887
- }
63888
- /**
63889
- * Locale-wrap a reference (Link to Entry).
63890
- */
63891
- static entryRef(entryId) {
63892
- return { "en-US": { sys: { type: "Link", linkType: "Entry", id: entryId } } };
63893
- }
63894
- // ─── Private ─────────────────────────────────────────────────────────────
63895
- authHeader() {
63896
- return { Authorization: `Bearer ${this.token}` };
63897
- }
63898
- async fetch(path, init2) {
63899
- const url2 = `${CONTENTFUL_MGMT}/spaces/${this.spaceId}/environments/${this.environment}${path}`;
63900
- return globalThis.fetch(url2, {
63901
- ...init2,
63902
- headers: { ...this.authHeader(), ...init2?.headers }
63903
- });
63904
- }
63905
- };
63906
-
63907
64180
  // src/services/snapshot.ts
64181
+ async function batchRun(items, fn, concurrency = 5) {
64182
+ const results = [];
64183
+ for (let i = 0; i < items.length; i += concurrency) {
64184
+ const batch = items.slice(i, i + concurrency);
64185
+ const settled = await Promise.allSettled(batch.map(fn));
64186
+ results.push(...settled);
64187
+ }
64188
+ return results;
64189
+ }
63908
64190
  async function ensureContentTypes(mgmt, config3 = {}) {
63909
64191
  const eventTypeId = config3.eventTypeId ?? "event";
63910
64192
  const themeTypeId = config3.themeTypeId ?? "theme";
63911
64193
  const faqTypeId = config3.faqTypeId ?? "faq";
64194
+ const organizationTypeId = config3.organizationTypeId ?? "organization";
63912
64195
  await mgmt.ensureContentType(themeTypeId, "Theme", [
63913
64196
  { id: "name", name: "Name", type: "Symbol", required: true },
63914
- { id: "path", name: "Path", type: "Symbol", required: true },
63915
- { id: "color", name: "Color", type: "Symbol" }
64197
+ { id: "path", name: "Path", type: "Symbol", required: true }
63916
64198
  ]);
63917
64199
  await mgmt.ensureContentType(eventTypeId, "Event", [
63918
64200
  { id: "title", name: "Title", type: "Symbol", required: true },
63919
64201
  { id: "slug", name: "Slug", type: "Symbol", required: true },
63920
64202
  { id: "theme", name: "Theme", type: "Link", linkType: "Entry" },
63921
- { id: "org", name: "Organization", type: "Symbol" },
64203
+ { id: "organization", name: "Organization", type: "Link", linkType: "Entry" },
63922
64204
  { id: "venue", name: "Venue", type: "Symbol" },
63923
- { id: "dateTime", name: "Date/Time", type: "Symbol" },
63924
- { id: "startsAt", name: "Starts At", type: "Date" },
63925
- { id: "endsAt", name: "Ends At", type: "Date" },
64205
+ { id: "date", name: "Date", type: "Date" },
64206
+ { id: "startTime", name: "Start Time", type: "Symbol" },
64207
+ { id: "endTime", name: "End Time", type: "Symbol" },
63926
64208
  { id: "price", name: "Price", type: "Symbol" },
63927
- { id: "backgroundColor", name: "Background Color", type: "Symbol" },
63928
64209
  { id: "image", name: "Image", type: "Link", linkType: "Asset" },
63929
- { id: "subtheme", name: "Subtheme", type: "Symbol" },
63930
64210
  { id: "isMajor", name: "Major Event", type: "Boolean" },
63931
64211
  { id: "maxSlots", name: "Max Slots", type: "Integer" },
63932
64212
  { id: "gformsUrl", name: "Google Forms URL", type: "Symbol" },
63933
- { id: "registrationOpensAt", name: "Registration Opens", type: "Date" },
63934
- { id: "registrationClosesAt", name: "Registration Closes", type: "Date" }
64213
+ { id: "gformsEditorUrl", name: "Google Forms Editor URL", type: "Symbol" },
64214
+ { id: "registrationClosesAt", name: "Registration Closes", type: "Date" },
64215
+ { id: "classCode", name: "Class Code", type: "Symbol" }
64216
+ ]);
64217
+ await mgmt.ensureContentType(organizationTypeId, "Organization", [
64218
+ { id: "name", name: "Name", type: "Symbol", required: true },
64219
+ { id: "acronym", name: "Acronym", type: "Symbol", required: true },
64220
+ { id: "logo", name: "Logo", type: "Link", linkType: "Asset" },
64221
+ { id: "link", name: "Link", type: "Symbol" }
63935
64222
  ]);
63936
64223
  await mgmt.ensureContentType(faqTypeId, "FAQ", [
63937
64224
  { id: "question", name: "Question", type: "Symbol", required: true },
63938
64225
  { id: "answer", name: "Answer", type: "Text", required: true },
63939
64226
  { id: "category", name: "Category", type: "Symbol" },
63940
- { id: "sortOrder", name: "Sort Order", type: "Integer" },
63941
- { id: "isActive", name: "Active", type: "Boolean" }
64227
+ { id: "sortOrder", name: "Sort Order", type: "Integer" }
63942
64228
  ]);
63943
64229
  }
63944
- async function pushToContentful(db, mgmt, config3 = {}) {
64230
+ var LAST_PUSH_KV_KEY = "contentful:last_push";
64231
+ async function pushToContentful(db, mgmt, config3 = {}, kv, forceFull = false) {
63945
64232
  const eventTypeId = config3.eventTypeId ?? "event";
63946
64233
  const themeTypeId = config3.themeTypeId ?? "theme";
63947
64234
  const faqTypeId = config3.faqTypeId ?? "faq";
64235
+ const organizationTypeId = config3.organizationTypeId ?? "organization";
63948
64236
  const result = {
63949
64237
  themesSynced: 0,
63950
64238
  eventsSynced: 0,
63951
64239
  faqsSynced: 0,
64240
+ organizationsSynced: 0,
63952
64241
  imagesUploaded: 0,
63953
64242
  imagesSkipped: 0,
63954
64243
  errors: []
63955
64244
  };
64245
+ let lastPushTs = 0;
64246
+ if (kv && !forceFull) {
64247
+ const stored = await kv.get(LAST_PUSH_KV_KEY);
64248
+ if (stored) lastPushTs = Number(stored) || 0;
64249
+ }
63956
64250
  try {
63957
64251
  const dbThemes = await db.query.themes.findMany();
63958
64252
  for (const theme of dbThemes) {
63959
64253
  try {
63960
64254
  await mgmt.upsertEntry(themeTypeId, theme.id, {
63961
64255
  name: ContentfulManagement.locale(theme.name),
63962
- path: ContentfulManagement.locale(theme.path),
63963
- color: ContentfulManagement.locale(theme.color)
64256
+ path: ContentfulManagement.locale(theme.path)
63964
64257
  });
63965
64258
  result.themesSynced++;
63966
64259
  } catch (err) {
@@ -63971,57 +64264,86 @@ async function pushToContentful(db, mgmt, config3 = {}) {
63971
64264
  result.errors.push(`Themes fetch failed: ${err}`);
63972
64265
  }
63973
64266
  try {
63974
- const dbEvents = await db.query.events.findMany();
63975
- for (const event of dbEvents) {
64267
+ const dbOrgs = await db.query.organizations.findMany();
64268
+ for (const org of dbOrgs) {
63976
64269
  try {
63977
64270
  const fields = {
63978
- title: ContentfulManagement.locale(event.title),
63979
- slug: ContentfulManagement.locale(event.slug),
63980
- isMajor: ContentfulManagement.locale(event.isMajor),
63981
- maxSlots: ContentfulManagement.locale(event.maxSlots)
64271
+ name: ContentfulManagement.locale(org.name),
64272
+ acronym: ContentfulManagement.locale(org.acronym),
64273
+ link: ContentfulManagement.locale(org.link)
63982
64274
  };
63983
- if (event.themeId) fields.theme = ContentfulManagement.entryRef(event.themeId);
63984
- if (event.org) fields.org = ContentfulManagement.locale(event.org);
63985
- if (event.venue) fields.venue = ContentfulManagement.locale(event.venue);
63986
- if (event.dateTime) fields.dateTime = ContentfulManagement.locale(event.dateTime);
63987
- if (event.price) fields.price = ContentfulManagement.locale(event.price);
63988
- if (event.backgroundColor) fields.backgroundColor = ContentfulManagement.locale(event.backgroundColor);
63989
- if (event.subtheme) fields.subtheme = ContentfulManagement.locale(event.subtheme);
63990
- if (event.gformsUrl) fields.gformsUrl = ContentfulManagement.locale(event.gformsUrl);
63991
- if (event.startsAt) fields.startsAt = ContentfulManagement.locale(new Date(event.startsAt * 1e3).toISOString());
63992
- if (event.endsAt) fields.endsAt = ContentfulManagement.locale(new Date(event.endsAt * 1e3).toISOString());
63993
- if (event.registrationOpensAt) fields.registrationOpensAt = ContentfulManagement.locale(new Date(event.registrationOpensAt * 1e3).toISOString());
63994
- if (event.registrationClosesAt) fields.registrationClosesAt = ContentfulManagement.locale(new Date(event.registrationClosesAt * 1e3).toISOString());
63995
- await mgmt.upsertEntry(eventTypeId, event.id, fields);
63996
- result.eventsSynced++;
64275
+ await mgmt.upsertEntry(organizationTypeId, org.id, fields);
64276
+ result.organizationsSynced++;
63997
64277
  } catch (err) {
63998
- result.errors.push(`Event ${event.id}: ${err}`);
64278
+ result.errors.push(`Organization ${org.id}: ${err}`);
63999
64279
  }
64000
64280
  }
64001
64281
  } catch (err) {
64002
- result.errors.push(`Events fetch failed: ${err}`);
64282
+ result.errors.push(`Organizations fetch failed: ${err}`);
64003
64283
  }
64004
64284
  try {
64005
- const dbFaqs = await db.query.faqs.findMany();
64006
- for (const faq of dbFaqs) {
64007
- try {
64008
- await mgmt.upsertEntry(faqTypeId, faq.id, {
64009
- question: ContentfulManagement.locale(faq.question),
64010
- answer: ContentfulManagement.locale(faq.answer),
64011
- category: ContentfulManagement.locale(faq.category),
64012
- sortOrder: ContentfulManagement.locale(faq.sortOrder),
64013
- isActive: ContentfulManagement.locale(faq.isActive)
64014
- });
64015
- result.faqsSynced++;
64016
- } catch (err) {
64017
- result.errors.push(`FAQ ${faq.id}: ${err}`);
64285
+ const dbEvents = lastPushTs > 0 ? await db.query.events.findMany().then((all) => all.filter((e) => {
64286
+ if (e.createdAt >= lastPushTs) return true;
64287
+ if (e.publishedAt && e.publishedAt >= lastPushTs) return true;
64288
+ if (!e.contentfulEntryId) return true;
64289
+ return false;
64290
+ })) : await db.query.events.findMany();
64291
+ await batchRun(dbEvents, async (event) => {
64292
+ const fields = {
64293
+ title: ContentfulManagement.locale(event.title),
64294
+ slug: ContentfulManagement.locale(event.slug),
64295
+ isMajor: ContentfulManagement.locale(event.isMajor),
64296
+ maxSlots: ContentfulManagement.locale(event.maxSlots)
64297
+ };
64298
+ if (event.themeId) fields.theme = ContentfulManagement.entryRef(event.themeId);
64299
+ if (event.organizationId) fields.organization = ContentfulManagement.entryRef(event.organizationId);
64300
+ if (event.venue) fields.venue = ContentfulManagement.locale(event.venue);
64301
+ if (event.dateTime) {
64302
+ const parsed = new Date(event.dateTime);
64303
+ fields.date = ContentfulManagement.locale(
64304
+ Number.isNaN(parsed.getTime()) ? event.dateTime : parsed.toISOString()
64305
+ );
64018
64306
  }
64019
- }
64307
+ if (event.price) fields.price = ContentfulManagement.locale(event.price);
64308
+ if (event.classCode) fields.classCode = ContentfulManagement.locale(event.classCode);
64309
+ if (event.startTime) fields.startTime = ContentfulManagement.locale(event.startTime);
64310
+ if (event.endTime) fields.endTime = ContentfulManagement.locale(event.endTime);
64311
+ if (event.gformsUrl) fields.gformsUrl = ContentfulManagement.locale(event.gformsUrl);
64312
+ if (event.gformsEditorUrl) fields.gformsEditorUrl = ContentfulManagement.locale(event.gformsEditorUrl);
64313
+ if (event.registrationClosesAt) fields.registrationClosesAt = ContentfulManagement.locale(new Date(event.registrationClosesAt * 1e3).toISOString());
64314
+ await mgmt.upsertEntry(eventTypeId, event.id, fields);
64315
+ if (!event.contentfulEntryId) {
64316
+ await db.update(events).set({ contentfulEntryId: event.id }).where(eq(events.id, event.id));
64317
+ }
64318
+ result.eventsSynced++;
64319
+ });
64320
+ } catch (err) {
64321
+ result.errors.push(`Events fetch failed: ${err}`);
64322
+ }
64323
+ try {
64324
+ const dbFaqs = lastPushTs > 0 ? await db.query.faqs.findMany().then((all) => all.filter((faq) => {
64325
+ if (faq.updatedAt >= lastPushTs) return true;
64326
+ if (faq.createdAt >= lastPushTs) return true;
64327
+ return false;
64328
+ })) : await db.query.faqs.findMany();
64329
+ await batchRun(dbFaqs, async (faq) => {
64330
+ await mgmt.upsertEntry(faqTypeId, faq.id, {
64331
+ question: ContentfulManagement.locale(faq.question),
64332
+ answer: ContentfulManagement.locale(faq.answer),
64333
+ category: ContentfulManagement.locale(faq.category),
64334
+ sortOrder: ContentfulManagement.locale(faq.sortOrder)
64335
+ });
64336
+ result.faqsSynced++;
64337
+ });
64020
64338
  } catch (err) {
64021
64339
  result.errors.push(`FAQs fetch failed: ${err}`);
64022
64340
  }
64341
+ if (kv) {
64342
+ const now2 = Math.floor(Date.now() / 1e3);
64343
+ await kv.put(LAST_PUSH_KV_KEY, String(now2));
64344
+ }
64023
64345
  console.log(
64024
- `[Push] Complete: ${result.themesSynced} themes, ${result.eventsSynced} events, ${result.faqsSynced} FAQs pushed to Contentful, ${result.errors.length} errors`
64346
+ `[Push] Complete: ${result.themesSynced} themes, ${result.organizationsSynced} organizations, ${result.eventsSynced} events, ${result.faqsSynced} FAQs pushed to Contentful, ${result.errors.length} errors`
64025
64347
  );
64026
64348
  return result;
64027
64349
  }
@@ -64059,19 +64381,30 @@ siteConfigRoute.patch("/:key", authMiddleware, adminMiddleware, async (c) => {
64059
64381
  });
64060
64382
  return c.json({ data: { key, value } });
64061
64383
  });
64384
+ var SYNC_LOCK_KEY = "contentful:sync:lock";
64385
+ var SYNC_LOCK_TTL = 60;
64062
64386
  siteConfigRoute.post("/sync-content", authMiddleware, adminMiddleware, async (c) => {
64063
64387
  if (!ContentfulManagement.isConfigured(c.env.CONTENTFUL_SPACE_ID, c.env.CONTENTFUL_MANAGEMENT_TOKEN)) {
64064
64388
  throw serviceUnavailable("Contentful Management API credentials not configured.");
64065
64389
  }
64066
- const mgmt = new ContentfulManagement(
64067
- c.env.CONTENTFUL_SPACE_ID,
64068
- c.env.CONTENTFUL_MANAGEMENT_TOKEN,
64069
- c.env.CONTENTFUL_ENVIRONMENT
64070
- );
64071
- const db = createDb(c.env.DB);
64072
- await ensureContentTypes(mgmt, {});
64073
- const result = await pushToContentful(db, mgmt, {});
64074
- return c.json({ data: result });
64390
+ const existingLock = await c.env.KV.get(SYNC_LOCK_KEY);
64391
+ if (existingLock) {
64392
+ throw conflict("A sync is already in progress. Please wait and try again.");
64393
+ }
64394
+ await c.env.KV.put(SYNC_LOCK_KEY, "1", { expirationTtl: SYNC_LOCK_TTL });
64395
+ try {
64396
+ const mgmt = new ContentfulManagement(
64397
+ c.env.CONTENTFUL_SPACE_ID,
64398
+ c.env.CONTENTFUL_MANAGEMENT_TOKEN,
64399
+ c.env.CONTENTFUL_ENVIRONMENT
64400
+ );
64401
+ const db = createDb(c.env.DB);
64402
+ await ensureContentTypes(mgmt, {});
64403
+ const result = await pushToContentful(db, mgmt, {}, c.env.KV);
64404
+ return c.json({ data: result });
64405
+ } finally {
64406
+ await c.env.KV.delete(SYNC_LOCK_KEY);
64407
+ }
64075
64408
  });
64076
64409
 
64077
64410
  // src/routes/faqs.ts
@@ -64107,8 +64440,7 @@ async function pushFaqToContentful(env2, faq) {
64107
64440
  "en-US": { question: faq.question },
64108
64441
  answer: { "en-US": faq.answer },
64109
64442
  category: { "en-US": faq.category },
64110
- sortOrder: { "en-US": faq.sortOrder },
64111
- isActive: { "en-US": faq.isActive }
64443
+ sortOrder: { "en-US": faq.sortOrder }
64112
64444
  });
64113
64445
  console.log(`[Contentful] Synced FAQ ${faq.id} successfully`);
64114
64446
  } catch (err) {
@@ -64121,7 +64453,6 @@ faqsRoute.get("/", async (c) => {
64121
64453
  const data = await cache3.getOrSet(
64122
64454
  FAQS_KV_KEY,
64123
64455
  () => db.query.faqs.findMany({
64124
- where: eq(faqs.isActive, true),
64125
64456
  orderBy: (t, { asc: asc2 }) => [asc2(t.sortOrder), asc2(t.createdAt)]
64126
64457
  }),
64127
64458
  FAQS_TTL
@@ -64159,11 +64490,9 @@ faqsRoute.delete("/:id", authMiddleware, adminMiddleware, async (c) => {
64159
64490
  const { id } = c.req.param();
64160
64491
  const db = createDb(c.env.DB);
64161
64492
  const cache3 = new CacheService(c.env.KV);
64162
- const now2 = Math.floor(Date.now() / 1e3);
64163
- const [deleted] = await db.update(faqs).set({ isActive: false, updatedAt: now2 }).where(eq(faqs.id, id)).returning();
64493
+ const [deleted] = await db.delete(faqs).where(eq(faqs.id, id)).returning();
64164
64494
  if (!deleted) throw notFound("FAQ");
64165
64495
  await cache3.del(FAQS_KV_KEY);
64166
- c.executionCtx.waitUntil(pushFaqToContentful(c.env, deleted));
64167
64496
  return c.json({ data: { deleted: true } });
64168
64497
  });
64169
64498
 
@@ -64317,6 +64646,49 @@ uploadsRoute.post(
64317
64646
  );
64318
64647
  }
64319
64648
  );
64649
+ uploadsRoute.post(
64650
+ "/contentful",
64651
+ authMiddleware,
64652
+ adminMiddleware,
64653
+ async (c) => {
64654
+ if (!ContentfulManagement.isConfigured(c.env.CONTENTFUL_SPACE_ID, c.env.CONTENTFUL_MANAGEMENT_TOKEN)) {
64655
+ throw serviceUnavailable("Contentful Management API credentials not configured.");
64656
+ }
64657
+ let formData;
64658
+ try {
64659
+ formData = await c.req.formData();
64660
+ } catch {
64661
+ throw badRequest("Request body must be multipart/form-data.");
64662
+ }
64663
+ const file2 = formData.get("file");
64664
+ if (!(file2 instanceof File)) {
64665
+ throw badRequest('A "file" field is required.');
64666
+ }
64667
+ const contentType = file2.type || "application/octet-stream";
64668
+ if (!ALLOWED_MIME_TYPES.has(contentType)) {
64669
+ throw badRequest(`Unsupported file type "${contentType}".`);
64670
+ }
64671
+ if (file2.size > MAX_FILE_SIZE) {
64672
+ throw badRequest("File exceeds 10MB limit.");
64673
+ }
64674
+ const mgmt = new ContentfulManagement(
64675
+ c.env.CONTENTFUL_SPACE_ID,
64676
+ c.env.CONTENTFUL_MANAGEMENT_TOKEN,
64677
+ c.env.CONTENTFUL_ENVIRONMENT
64678
+ );
64679
+ const arrayBuffer = await file2.arrayBuffer();
64680
+ const uploadId = await mgmt.uploadFile(file2.name, arrayBuffer, contentType);
64681
+ const asset = await mgmt.createAssetFromUpload(uploadId, file2.name, file2.name, contentType);
64682
+ return c.json({
64683
+ data: {
64684
+ assetId: asset.id,
64685
+ url: asset.url,
64686
+ size: file2.size,
64687
+ contentType
64688
+ }
64689
+ }, 201);
64690
+ }
64691
+ );
64320
64692
  function sanitizeFolder(raw2) {
64321
64693
  if (typeof raw2 !== "string" || !raw2.trim()) return "images";
64322
64694
  return raw2.trim().replace(/[^a-zA-Z0-9\-_/]/g, "").replace(/\/+/g, "/").replace(/^\/|\/$/g, "") || "images";
@@ -64333,10 +64705,43 @@ function extensionFromMime(mime) {
64333
64705
  }
64334
64706
 
64335
64707
  // src/routes/themes.ts
64708
+ var CF_THEME_CT = "theme";
64709
+ async function pushThemeToContentful(env2, theme) {
64710
+ if (!ContentfulManagement.isConfigured(env2.CONTENTFUL_SPACE_ID, env2.CONTENTFUL_MANAGEMENT_TOKEN)) return;
64711
+ const mgmt = new ContentfulManagement(
64712
+ env2.CONTENTFUL_SPACE_ID,
64713
+ env2.CONTENTFUL_MANAGEMENT_TOKEN,
64714
+ env2.CONTENTFUL_ENVIRONMENT
64715
+ );
64716
+ try {
64717
+ await mgmt.upsertEntry(CF_THEME_CT, theme.id, {
64718
+ name: ContentfulManagement.locale(theme.name),
64719
+ path: ContentfulManagement.locale(theme.path)
64720
+ });
64721
+ } catch (err) {
64722
+ console.warn(`[Contentful] Failed to sync theme ${theme.id}:`, err);
64723
+ }
64724
+ }
64725
+ async function deleteThemeFromContentful(env2, themeId) {
64726
+ if (!ContentfulManagement.isConfigured(env2.CONTENTFUL_SPACE_ID, env2.CONTENTFUL_MANAGEMENT_TOKEN)) return;
64727
+ const mgmt = new ContentfulManagement(
64728
+ env2.CONTENTFUL_SPACE_ID,
64729
+ env2.CONTENTFUL_MANAGEMENT_TOKEN,
64730
+ env2.CONTENTFUL_ENVIRONMENT
64731
+ );
64732
+ try {
64733
+ const entry = await mgmt.getEntry(themeId);
64734
+ if (entry) {
64735
+ await mgmt.unpublishEntry(themeId);
64736
+ await mgmt.deleteEntry(themeId);
64737
+ }
64738
+ } catch (err) {
64739
+ console.warn(`[Contentful] Failed to delete theme ${themeId} from Contentful:`, err);
64740
+ }
64741
+ }
64336
64742
  var createThemeSchema = external_exports.object({
64337
64743
  name: external_exports.string().min(1),
64338
- path: external_exports.string().min(1),
64339
- color: external_exports.string().optional()
64744
+ path: external_exports.string().min(1)
64340
64745
  });
64341
64746
  var themesRoute = new Hono2();
64342
64747
  themesRoute.get("/", async (c) => {
@@ -64354,6 +64759,7 @@ themesRoute.post(
64354
64759
  const db = createDb(c.env.DB);
64355
64760
  try {
64356
64761
  const [created] = await db.insert(themes).values(body).returning();
64762
+ c.executionCtx.waitUntil(pushThemeToContentful(c.env, created));
64357
64763
  return c.json({ data: created }, 201);
64358
64764
  } catch (err) {
64359
64765
  if (err.message && err.message.includes("UNIQUE constraint failed")) {
@@ -64374,6 +64780,7 @@ themesRoute.patch(
64374
64780
  try {
64375
64781
  const [updated] = await db.update(themes).set(body).where(eq(themes.id, id)).returning();
64376
64782
  if (!updated) throw notFound("Theme");
64783
+ c.executionCtx.waitUntil(pushThemeToContentful(c.env, updated));
64377
64784
  return c.json({ data: updated });
64378
64785
  } catch (err) {
64379
64786
  if (err.message && err.message.includes("UNIQUE constraint failed")) {
@@ -64388,6 +64795,67 @@ themesRoute.delete("/:id", authMiddleware, adminMiddleware, async (c) => {
64388
64795
  const db = createDb(c.env.DB);
64389
64796
  const [deleted] = await db.delete(themes).where(eq(themes.id, id)).returning();
64390
64797
  if (!deleted) throw notFound("Theme");
64798
+ c.executionCtx.waitUntil(deleteThemeFromContentful(c.env, id));
64799
+ return c.body(null, 204);
64800
+ });
64801
+
64802
+ // src/routes/organizations.ts
64803
+ var createOrganizationSchema = external_exports.object({
64804
+ name: external_exports.string().min(1),
64805
+ acronym: external_exports.string().min(1),
64806
+ logoUrl: external_exports.string().url().nullable().optional(),
64807
+ link: external_exports.string().url().nullable().optional()
64808
+ });
64809
+ var organizationsRoute = new Hono2();
64810
+ organizationsRoute.get("/", async (c) => {
64811
+ const db = createDb(c.env.DB);
64812
+ const data = await db.select().from(organizations);
64813
+ return c.json({ data });
64814
+ });
64815
+ organizationsRoute.post(
64816
+ "/",
64817
+ authMiddleware,
64818
+ adminMiddleware,
64819
+ zValidator("json", createOrganizationSchema),
64820
+ async (c) => {
64821
+ const body = c.req.valid("json");
64822
+ const db = createDb(c.env.DB);
64823
+ try {
64824
+ const [created] = await db.insert(organizations).values(body).returning();
64825
+ return c.json({ data: created }, 201);
64826
+ } catch (err) {
64827
+ if (err.message && err.message.includes("UNIQUE constraint failed")) {
64828
+ throw conflict("An organization with this name or acronym already exists.");
64829
+ }
64830
+ throw err;
64831
+ }
64832
+ }
64833
+ );
64834
+ organizationsRoute.patch(
64835
+ "/:id",
64836
+ authMiddleware,
64837
+ adminMiddleware,
64838
+ async (c) => {
64839
+ const { id } = c.req.param();
64840
+ const body = await c.req.json();
64841
+ const db = createDb(c.env.DB);
64842
+ try {
64843
+ const [updated] = await db.update(organizations).set(body).where(eq(organizations.id, id)).returning();
64844
+ if (!updated) throw notFound("Organization");
64845
+ return c.json({ data: updated });
64846
+ } catch (err) {
64847
+ if (err.message && err.message.includes("UNIQUE constraint failed")) {
64848
+ throw conflict("An organization with this name or acronym already exists.");
64849
+ }
64850
+ throw err;
64851
+ }
64852
+ }
64853
+ );
64854
+ organizationsRoute.delete("/:id", authMiddleware, adminMiddleware, async (c) => {
64855
+ const { id } = c.req.param();
64856
+ const db = createDb(c.env.DB);
64857
+ const [deleted] = await db.delete(organizations).where(eq(organizations.id, id)).returning();
64858
+ if (!deleted) throw notFound("Organization");
64391
64859
  return c.body(null, 204);
64392
64860
  });
64393
64861
 
@@ -64435,12 +64903,13 @@ function createApp(options = {}) {
64435
64903
  });
64436
64904
  app2.post(POW_VERIFY_PATH, handlePowVerify);
64437
64905
  app2.route("/health", healthRoute);
64438
- app2.route("/config", siteConfigRoute);
64439
- app2.route("/events", eventsRoute);
64440
- app2.route("/themes", themesRoute);
64441
- app2.route("/users", usersRoute);
64442
- app2.route("/faqs", faqsRoute);
64443
- app2.route("/uploads", uploadsRoute);
64906
+ app2.route("/api/config", siteConfigRoute);
64907
+ app2.route("/api/events", eventsRoute);
64908
+ app2.route("/api/themes", themesRoute);
64909
+ app2.route("/api/users", usersRoute);
64910
+ app2.route("/api/organizations", organizationsRoute);
64911
+ app2.route("/api/faqs", faqsRoute);
64912
+ app2.route("/api/uploads", uploadsRoute);
64444
64913
  app2.route("/internal/gforms-webhook", gformsWebhookRoute);
64445
64914
  app2.onError(errorHandler2);
64446
64915
  app2.notFound(
@@ -64655,11 +65124,12 @@ var ResendService = class {
64655
65124
  }
64656
65125
  };
64657
65126
  function buildReminderEmail(event) {
65127
+ const timeDisplay = [event.dateTime, event.startTime].filter(Boolean).join(" at ");
64658
65128
  return `
64659
65129
  <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 24px;">
64660
65130
  <h2 style="color: #1a1a2e;">\u{1F4C5} Reminder: ${event.title}</h2>
64661
- ${event.org ? `<p style="color: #666;">Organized by: <strong>${event.org}</strong></p>` : ""}
64662
- ${event.dateTime ? `<p>\u{1F550} <strong>${event.dateTime}</strong></p>` : ""}
65131
+ ${event.organization ? `<p style="color: #666;">Organized by: <strong>${event.organization}</strong></p>` : ""}
65132
+ ${timeDisplay ? `<p>\u{1F550} <strong>${timeDisplay}</strong></p>` : ""}
64663
65133
  ${event.venue ? `<p>\u{1F4CD} <strong>${event.venue}</strong></p>` : ""}
64664
65134
  <hr style="margin: 24px 0; border: none; border-top: 1px solid #eee;" />
64665
65135
  ${event.gformsUrl ? `<p>You registered for this event. See you there!</p>
@@ -64802,72 +65272,74 @@ function createQueueHandler(env2) {
64802
65272
  };
64803
65273
  }
64804
65274
  async function processJob(job, services) {
64805
- const { db, email: email3, gforms, env: env2 } = services;
65275
+ const { db, email: email3, gforms } = services;
64806
65276
  switch (job.type) {
64807
65277
  case "send_email": {
64808
- if (!email3) throw new Error("Email provider not configured (SES credentials missing)");
65278
+ if (!email3) throw new Error("Email provider not configured");
64809
65279
  const result = await email3.sendEmail(job.payload);
64810
65280
  console.log(`[Queue] send_email dispatched via ${result.provider} (id=${result.id})`);
64811
65281
  break;
64812
65282
  }
64813
65283
  case "send_reminder_email": {
64814
- if (!email3) throw new Error("Email provider not configured (SES credentials missing)");
65284
+ if (!email3) throw new Error("Email provider not configured");
64815
65285
  const event = await db.query.events.findFirst({
64816
- where: eq(events.id, job.payload.eventId)
65286
+ where: eq(events.id, job.payload.eventId),
65287
+ with: { organization: true }
64817
65288
  });
64818
65289
  if (!event?.gformsId) break;
64819
65290
  const emails = await gforms.getRespondentEmails(event.gformsId);
64820
65291
  if (emails.length === 0) break;
64821
65292
  const isDay = job.payload.hoursBeforeEvent === 24;
64822
- const subject = isDay ? `Reminder: "${event.title}" is tomorrow!` : `Reminder: "${event.title}" starts in 1 hour!`;
64823
- const html2 = buildReminderEmail(event);
64824
- const BATCH_SIZE = 100;
65293
+ const subject = isDay ? `Reminder: "${event.title}" is tomorrow!` : `Reminder: "${event.title}" is in 1 hour!`;
65294
+ const html2 = buildReminderEmail({
65295
+ title: event.title,
65296
+ organization: event.organization?.name ?? null,
65297
+ dateTime: event.dateTime,
65298
+ startTime: event.startTime,
65299
+ venue: event.venue,
65300
+ gformsUrl: event.gformsUrl
65301
+ });
65302
+ const BATCH_SIZE = 50;
65303
+ const chunks = [];
64825
65304
  for (let i = 0; i < emails.length; i += BATCH_SIZE) {
64826
- const chunk = emails.slice(i, i + BATCH_SIZE).map((to) => ({ to, subject, html: html2 }));
64827
- const results = await email3.sendBatch(chunk);
64828
- const failures = results.filter((r) => r.status === "rejected");
64829
- if (failures.length > 0) {
65305
+ chunks.push(emails.slice(i, i + BATCH_SIZE));
65306
+ }
65307
+ const failures = [];
65308
+ for (let i = 0; i < chunks.length; i++) {
65309
+ const chunk = chunks[i];
65310
+ try {
65311
+ await email3.sendEmail({ to: chunk, subject, html: html2 });
65312
+ } catch (err) {
65313
+ failures.push(...chunk);
64830
65314
  console.error(
64831
65315
  `[Queue] send_reminder_email: ${failures.length}/${chunk.length} messages failed in batch ${i / BATCH_SIZE + 1}`,
64832
- failures.map((f) => f.reason)
65316
+ err
64833
65317
  );
64834
65318
  }
64835
65319
  }
64836
- if (isDay) {
64837
- await db.update(events).set({ reminder24hSent: true }).where(eq(events.id, job.payload.eventId));
64838
- } else {
64839
- await db.update(events).set({ reminder1hSent: true }).where(eq(events.id, job.payload.eventId));
64840
- }
65320
+ const field = isDay ? "reminder24hSent" : "reminder1hSent";
65321
+ await db.update(events).set({ [field]: true }).where(eq(events.id, event.id));
64841
65322
  break;
64842
65323
  }
64843
65324
  case "audit_log": {
64844
- console.log("[Audit]", job.payload.action, job.payload.userId, job.payload.meta);
65325
+ console.log(`[Queue] audit_log: ${job.payload.action} by ${job.payload.userId}`);
64845
65326
  break;
64846
65327
  }
64847
- case "notify_batch_release": {
64848
- console.log("[Release] Events published:", job.payload.eventIds.join(", "));
65328
+ case "snapshot_content": {
65329
+ console.log(`[Queue] snapshot_content triggered at ${job.payload.triggeredAt}`);
64849
65330
  break;
64850
65331
  }
64851
- case "renew_forms_watch": {
64852
- const renewed = await gforms.renewWatch(job.payload.formId, job.payload.watchId);
64853
- const newExpiry = Math.floor(new Date(renewed.expireTime).getTime() / 1e3);
64854
- await db.update(events).set({ watchExpiresAt: newExpiry }).where(eq(events.gformsId, job.payload.formId));
65332
+ case "notify_batch_release": {
65333
+ console.log(`[Queue] notify_batch_release: ${job.payload.eventIds.length} events released`);
64855
65334
  break;
64856
65335
  }
64857
- case "snapshot_content": {
64858
- console.log("[Snapshot] Content snapshot triggered at", job.payload.triggeredAt);
64859
- if (!ContentfulManagement.isConfigured(env2.CONTENTFUL_SPACE_ID, env2.CONTENTFUL_MANAGEMENT_TOKEN)) {
64860
- console.warn("[Snapshot] Contentful Management API credentials not configured \u2014 skipping");
64861
- break;
65336
+ case "renew_forms_watch": {
65337
+ try {
65338
+ await gforms.renewWatch(job.payload.formId, job.payload.watchId);
65339
+ console.log(`[Queue] renew_forms_watch: renewed watch for form ${job.payload.formId}`);
65340
+ } catch (err) {
65341
+ console.error(`[Queue] renew_forms_watch failed for form ${job.payload.formId}:`, err);
64862
65342
  }
64863
- const mgmt = new ContentfulManagement(
64864
- env2.CONTENTFUL_SPACE_ID,
64865
- env2.CONTENTFUL_MANAGEMENT_TOKEN,
64866
- env2.CONTENTFUL_ENVIRONMENT
64867
- );
64868
- await ensureContentTypes(mgmt, {});
64869
- const result = await pushToContentful(db, mgmt, {});
64870
- console.log("[Snapshot] Result:", JSON.stringify(result));
64871
65343
  break;
64872
65344
  }
64873
65345
  }
@@ -64944,9 +65416,12 @@ async function reconcileSlots(env2) {
64944
65416
  }
64945
65417
 
64946
65418
  // src/cron/reminder-emails.ts
64947
- var SECONDS_IN_24H = 86400;
64948
- var SECONDS_IN_1H = 3600;
64949
- var WINDOW = 3600;
65419
+ function parseStartTimestamp(dateTime, startTime) {
65420
+ if (!dateTime) return null;
65421
+ const combined = startTime ? `${dateTime} ${startTime}` : dateTime;
65422
+ const ms = Date.parse(combined);
65423
+ return Number.isNaN(ms) ? null : Math.floor(ms / 1e3);
65424
+ }
64950
65425
  async function reminderEmails(env2) {
64951
65426
  if (!env2.EMAIL_QUEUE) {
64952
65427
  console.warn(
@@ -64964,17 +65439,21 @@ async function reminderEmails(env2) {
64964
65439
  }
64965
65440
  const db = createDb(env2.DB);
64966
65441
  const now2 = Math.floor(Date.now() / 1e3);
64967
- const events24h = await db.query.events.findMany({
65442
+ const candidates24h = await db.query.events.findMany({
64968
65443
  where: and(
64969
65444
  eq(events.status, "published"),
64970
- eq(events.reminder24hSent, false),
64971
- lte(events.startsAt, now2 + SECONDS_IN_24H + WINDOW)
65445
+ eq(events.reminder24hSent, false)
64972
65446
  ),
64973
- columns: { id: true, slug: true, startsAt: true }
65447
+ columns: {
65448
+ id: true,
65449
+ dateTime: true,
65450
+ startTime: true
65451
+ }
64974
65452
  });
64975
- for (const event of events24h) {
64976
- if (!event.startsAt) continue;
64977
- const hoursUntil = (event.startsAt - now2) / 3600;
65453
+ for (const event of candidates24h) {
65454
+ const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
65455
+ if (!startsAt) continue;
65456
+ const hoursUntil = (startsAt - now2) / 3600;
64978
65457
  if (hoursUntil <= 25 && hoursUntil >= 23) {
64979
65458
  await env2.EMAIL_QUEUE.send({
64980
65459
  type: "send_reminder_email",
@@ -64982,27 +65461,28 @@ async function reminderEmails(env2) {
64982
65461
  });
64983
65462
  }
64984
65463
  }
64985
- const events1h = await db.query.events.findMany({
65464
+ const candidates1h = await db.query.events.findMany({
64986
65465
  where: and(
64987
65466
  eq(events.status, "published"),
64988
- eq(events.reminder1hSent, false),
64989
- lte(events.startsAt, now2 + SECONDS_IN_1H + WINDOW)
65467
+ eq(events.reminder1hSent, false)
64990
65468
  ),
64991
- columns: { id: true, slug: true, startsAt: true }
65469
+ columns: {
65470
+ id: true,
65471
+ dateTime: true,
65472
+ startTime: true
65473
+ }
64992
65474
  });
64993
- for (const event of events1h) {
64994
- if (!event.startsAt) continue;
64995
- const minutesUntil = (event.startsAt - now2) / 60;
64996
- if (minutesUntil <= 65 && minutesUntil >= 55) {
65475
+ for (const event of candidates1h) {
65476
+ const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
65477
+ if (!startsAt) continue;
65478
+ const hoursUntil = (startsAt - now2) / 3600;
65479
+ if (hoursUntil <= 1.5 && hoursUntil >= 0) {
64997
65480
  await env2.EMAIL_QUEUE.send({
64998
65481
  type: "send_reminder_email",
64999
65482
  payload: { eventId: event.id, hoursBeforeEvent: 1 }
65000
65483
  });
65001
65484
  }
65002
65485
  }
65003
- console.log(
65004
- `[reminder-emails] Queued ${events24h.length} 24h reminders, ${events1h.length} 1h reminders.`
65005
- );
65006
65486
  }
65007
65487
 
65008
65488
  // src/cron/lifecycle-check.ts
@@ -65018,7 +65498,6 @@ async function lifecycleCheck(env2, ctx) {
65018
65498
  if (!siteEndsAt || snapshotCompleted) return;
65019
65499
  if (now2 >= siteEndsAt) {
65020
65500
  console.log("[lifecycle-check] siteEndsAt passed \u2014 triggering content snapshot.");
65021
- await db.update(siteConfig).set({ value: "true", updatedAt: now2 }).where(eq(siteConfig.key, "snapshot_completed"));
65022
65501
  if (env2.EMAIL_QUEUE) {
65023
65502
  ctx.waitUntil(
65024
65503
  env2.EMAIL_QUEUE.send({ type: "snapshot_content", payload: { triggeredAt: now2 } })
@@ -65056,73 +65535,183 @@ async function renewWatches(env2) {
65056
65535
  }
65057
65536
  console.log(`[renew-watches] Renewed ${renewed}/${watchEvents.length} watches.`);
65058
65537
  }
65059
- function readMigrationFiles(config3) {
65060
- const migrationFolderTo = config3.migrationsFolder;
65061
- const migrationQueries = [];
65062
- const journalPath = `${migrationFolderTo}/meta/_journal.json`;
65063
- if (!fs.existsSync(journalPath)) {
65064
- throw new Error(`Can't find meta/_journal.json file`);
65065
- }
65066
- const journalAsString = fs.readFileSync(`${migrationFolderTo}/meta/_journal.json`).toString();
65067
- const journal = JSON.parse(journalAsString);
65068
- for (const journalEntry of journal.entries) {
65069
- const migrationPath = `${migrationFolderTo}/${journalEntry.tag}.sql`;
65538
+
65539
+ // src/db/migrate.ts
65540
+ var PATCH_STATEMENTS = [
65541
+ `ALTER TABLE "themes" ADD COLUMN "updated_at" integer NOT NULL DEFAULT (unixepoch())`,
65542
+ `ALTER TABLE "events" ADD COLUMN "organization_id" text`,
65543
+ `ALTER TABLE "events" ADD COLUMN "class_code" text`,
65544
+ `ALTER TABLE "events" ADD COLUMN "start_time" text`,
65545
+ `ALTER TABLE "events" ADD COLUMN "end_time" text`,
65546
+ `CREATE INDEX IF NOT EXISTS "idx_events_organization_id" ON "events" ("organization_id")`
65547
+ ];
65548
+ var CREATE_STATEMENTS = [
65549
+ // Better Auth: user
65550
+ `CREATE TABLE IF NOT EXISTS "user" (
65551
+ "id" text PRIMARY KEY NOT NULL,
65552
+ "name" text NOT NULL,
65553
+ "email" text NOT NULL,
65554
+ "email_verified" integer DEFAULT false NOT NULL,
65555
+ "image" text,
65556
+ "created_at" integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
65557
+ "updated_at" integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
65558
+ )`,
65559
+ `CREATE UNIQUE INDEX IF NOT EXISTS "user_email_unique" ON "user" ("email")`,
65560
+ // Better Auth: session
65561
+ `CREATE TABLE IF NOT EXISTS "session" (
65562
+ "id" text PRIMARY KEY NOT NULL,
65563
+ "expires_at" integer NOT NULL,
65564
+ "token" text NOT NULL,
65565
+ "created_at" integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
65566
+ "updated_at" integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
65567
+ "ip_address" text,
65568
+ "user_agent" text,
65569
+ "user_id" text NOT NULL,
65570
+ FOREIGN KEY ("user_id") REFERENCES "user"("id") ON UPDATE no action ON DELETE cascade
65571
+ )`,
65572
+ `CREATE UNIQUE INDEX IF NOT EXISTS "session_token_unique" ON "session" ("token")`,
65573
+ `CREATE INDEX IF NOT EXISTS "session_userId_idx" ON "session" ("user_id")`,
65574
+ // Better Auth: account
65575
+ `CREATE TABLE IF NOT EXISTS "account" (
65576
+ "id" text PRIMARY KEY NOT NULL,
65577
+ "account_id" text NOT NULL,
65578
+ "provider_id" text NOT NULL,
65579
+ "user_id" text NOT NULL,
65580
+ "access_token" text,
65581
+ "refresh_token" text,
65582
+ "id_token" text,
65583
+ "access_token_expires_at" integer,
65584
+ "refresh_token_expires_at" integer,
65585
+ "scope" text,
65586
+ "password" text,
65587
+ "created_at" integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
65588
+ "updated_at" integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
65589
+ FOREIGN KEY ("user_id") REFERENCES "user"("id") ON UPDATE no action ON DELETE cascade
65590
+ )`,
65591
+ `CREATE INDEX IF NOT EXISTS "account_userId_idx" ON "account" ("user_id")`,
65592
+ // Better Auth: verification
65593
+ `CREATE TABLE IF NOT EXISTS "verification" (
65594
+ "id" text PRIMARY KEY NOT NULL,
65595
+ "identifier" text NOT NULL,
65596
+ "value" text NOT NULL,
65597
+ "expires_at" integer NOT NULL,
65598
+ "created_at" integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
65599
+ "updated_at" integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
65600
+ )`,
65601
+ `CREATE INDEX IF NOT EXISTS "verification_identifier_idx" ON "verification" ("identifier")`,
65602
+ // App: users
65603
+ `CREATE TABLE IF NOT EXISTS "users" (
65604
+ "id" text PRIMARY KEY NOT NULL,
65605
+ "better_auth_id" text NOT NULL,
65606
+ "email" text NOT NULL,
65607
+ "name" text NOT NULL,
65608
+ "role" text DEFAULT 'student' NOT NULL,
65609
+ "created_at" integer DEFAULT (unixepoch()) NOT NULL
65610
+ )`,
65611
+ `CREATE UNIQUE INDEX IF NOT EXISTS "users_better_auth_id_unique" ON "users" ("better_auth_id")`,
65612
+ `CREATE UNIQUE INDEX IF NOT EXISTS "users_email_unique" ON "users" ("email")`,
65613
+ // App: organizations (before events, due to FK)
65614
+ `CREATE TABLE IF NOT EXISTS "organizations" (
65615
+ "id" text PRIMARY KEY NOT NULL,
65616
+ "name" text NOT NULL,
65617
+ "acronym" text NOT NULL,
65618
+ "logo_url" text,
65619
+ "link" text,
65620
+ "created_at" integer DEFAULT (unixepoch()) NOT NULL
65621
+ )`,
65622
+ `CREATE UNIQUE INDEX IF NOT EXISTS "organizations_name_unique" ON "organizations" ("name")`,
65623
+ `CREATE UNIQUE INDEX IF NOT EXISTS "organizations_acronym_unique" ON "organizations" ("acronym")`,
65624
+ // App: themes (before events, due to FK)
65625
+ `CREATE TABLE IF NOT EXISTS "themes" (
65626
+ "id" text PRIMARY KEY NOT NULL,
65627
+ "name" text NOT NULL,
65628
+ "path" text NOT NULL,
65629
+ "created_at" integer NOT NULL,
65630
+ "updated_at" integer NOT NULL DEFAULT (unixepoch())
65631
+ )`,
65632
+ `CREATE UNIQUE INDEX IF NOT EXISTS "themes_name_unique" ON "themes" ("name")`,
65633
+ `CREATE UNIQUE INDEX IF NOT EXISTS "themes_path_unique" ON "themes" ("path")`,
65634
+ // App: events
65635
+ `CREATE TABLE IF NOT EXISTS "events" (
65636
+ "id" text PRIMARY KEY NOT NULL,
65637
+ "slug" text NOT NULL,
65638
+ "theme_id" text,
65639
+ "organization_id" text,
65640
+ "title" text NOT NULL,
65641
+ "description" text,
65642
+ "venue" text,
65643
+ "date_time" text,
65644
+ "price" text,
65645
+ "background_image_url" text,
65646
+ "class_code" text,
65647
+ "start_time" text,
65648
+ "end_time" text,
65649
+ "is_major" integer DEFAULT false NOT NULL,
65650
+ "max_slots" integer DEFAULT 0 NOT NULL,
65651
+ "registered_slots" integer DEFAULT 0 NOT NULL,
65652
+ "gforms_id" text,
65653
+ "gforms_url" text,
65654
+ "gforms_editor_url" text,
65655
+ "registration_closes_at" integer,
65656
+ "watch_id" text,
65657
+ "watch_expires_at" integer,
65658
+ "status" text DEFAULT 'draft' NOT NULL,
65659
+ "release_at" integer,
65660
+ "reminder_24h_sent" integer DEFAULT false NOT NULL,
65661
+ "reminder_1h_sent" integer DEFAULT false NOT NULL,
65662
+ "contentful_entry_id" text,
65663
+ "created_at" integer DEFAULT (unixepoch()) NOT NULL,
65664
+ "published_at" integer,
65665
+ FOREIGN KEY ("theme_id") REFERENCES "themes"("id") ON UPDATE no action ON DELETE set null,
65666
+ FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE no action ON DELETE set null
65667
+ )`,
65668
+ `CREATE UNIQUE INDEX IF NOT EXISTS "events_slug_unique" ON "events" ("slug")`,
65669
+ `CREATE INDEX IF NOT EXISTS "idx_events_status_release" ON "events" ("status", "release_at")`,
65670
+ `CREATE INDEX IF NOT EXISTS "idx_events_theme_id" ON "events" ("theme_id")`,
65671
+ `CREATE INDEX IF NOT EXISTS "idx_events_organization_id" ON "events" ("organization_id")`,
65672
+ `CREATE INDEX IF NOT EXISTS "idx_events_slug" ON "events" ("slug")`,
65673
+ // App: faqs
65674
+ `CREATE TABLE IF NOT EXISTS "faqs" (
65675
+ "id" text PRIMARY KEY NOT NULL,
65676
+ "question" text NOT NULL,
65677
+ "answer" text NOT NULL,
65678
+ "category" text,
65679
+ "sort_order" integer DEFAULT 0 NOT NULL,
65680
+ "created_at" integer DEFAULT (unixepoch()) NOT NULL,
65681
+ "updated_at" integer DEFAULT (unixepoch()) NOT NULL
65682
+ )`,
65683
+ // App: site_config
65684
+ `CREATE TABLE IF NOT EXISTS "site_config" (
65685
+ "key" text PRIMARY KEY NOT NULL,
65686
+ "value" text NOT NULL,
65687
+ "updated_at" integer DEFAULT (unixepoch()) NOT NULL
65688
+ )`,
65689
+ // App: bookmarks
65690
+ `CREATE TABLE IF NOT EXISTS "bookmarks" (
65691
+ "id" text PRIMARY KEY NOT NULL,
65692
+ "user_id" text NOT NULL,
65693
+ "event_id" text NOT NULL,
65694
+ "created_at" integer DEFAULT (unixepoch()) NOT NULL,
65695
+ FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE no action ON DELETE cascade,
65696
+ FOREIGN KEY ("event_id") REFERENCES "events"("id") ON UPDATE no action ON DELETE cascade
65697
+ )`,
65698
+ `CREATE UNIQUE INDEX IF NOT EXISTS "idx_bookmarks_user_event" ON "bookmarks" ("user_id", "event_id")`
65699
+ ];
65700
+ async function ensureDatabase(d1) {
65701
+ const { results } = await d1.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='user'").all();
65702
+ if (results.length === 0) {
65703
+ for (const sql3 of CREATE_STATEMENTS) {
65704
+ await d1.prepare(sql3).run();
65705
+ }
65706
+ }
65707
+ for (const sql3 of PATCH_STATEMENTS) {
65070
65708
  try {
65071
- const query = fs.readFileSync(`${migrationFolderTo}/${journalEntry.tag}.sql`).toString();
65072
- const result = query.split("--> statement-breakpoint").map((it) => {
65073
- return it;
65074
- });
65075
- migrationQueries.push({
65076
- sql: result,
65077
- bps: journalEntry.breakpoints,
65078
- folderMillis: journalEntry.when,
65079
- hash: crypto2.createHash("sha256").update(query).digest("hex")
65080
- });
65081
- } catch {
65082
- throw new Error(`No file ${migrationPath} found in ${migrationFolderTo} folder`);
65083
- }
65084
- }
65085
- return migrationQueries;
65086
- }
65087
-
65088
- // node_modules/drizzle-orm/d1/migrator.js
65089
- async function migrate(db, config3) {
65090
- const migrations = readMigrationFiles(config3);
65091
- const migrationsTable = config3.migrationsTable ?? "__drizzle_migrations";
65092
- const migrationTableCreate = sql2`
65093
- CREATE TABLE IF NOT EXISTS ${sql2.identifier(migrationsTable)} (
65094
- id SERIAL PRIMARY KEY,
65095
- hash text NOT NULL,
65096
- created_at numeric
65097
- )
65098
- `;
65099
- await db.session.run(migrationTableCreate);
65100
- const dbMigrations = await db.values(
65101
- sql2`SELECT id, hash, created_at FROM ${sql2.identifier(migrationsTable)} ORDER BY created_at DESC LIMIT 1`
65102
- );
65103
- const lastDbMigration = dbMigrations[0] ?? void 0;
65104
- const statementToBatch = [];
65105
- for (const migration of migrations) {
65106
- if (!lastDbMigration || Number(lastDbMigration[2]) < migration.folderMillis) {
65107
- for (const stmt of migration.sql) {
65108
- statementToBatch.push(db.run(sql2.raw(stmt)));
65109
- }
65110
- statementToBatch.push(
65111
- db.run(
65112
- sql2`INSERT INTO ${sql2.identifier(migrationsTable)} ("hash", "created_at") VALUES(${sql2.raw(`'${migration.hash}'`)}, ${sql2.raw(`${migration.folderMillis}`)})`
65113
- )
65114
- );
65709
+ await d1.prepare(sql3).run();
65710
+ } catch (err) {
65711
+ if (err?.message?.includes("duplicate column")) continue;
65712
+ throw err;
65115
65713
  }
65116
65714
  }
65117
- if (statementToBatch.length > 0) {
65118
- await db.session.batch(statementToBatch);
65119
- }
65120
- }
65121
-
65122
- // src/db/migrate.ts
65123
- async function ensureDatabase(d1, migrationsFolder = "./drizzle") {
65124
- const db = createDb(d1);
65125
- await migrate(db, { migrationsFolder });
65126
65715
  }
65127
65716
 
65128
65717
  // src/index.ts
@@ -65154,7 +65743,7 @@ function createLeapify(options = {}) {
65154
65743
  if (options.autoMigrate && !migrated) {
65155
65744
  migrated = true;
65156
65745
  try {
65157
- await ensureDatabase(env2.DB, options.migrationsFolder);
65746
+ await ensureDatabase(env2.DB);
65158
65747
  } catch (err) {
65159
65748
  console.error("[leapify] Auto-migration failed:", err);
65160
65749
  }