@access-dlsu/leapify 0.260601.2 → 0.260604.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/app.d.ts.map +1 -1
  2. package/dist/auth/auth.d.ts +2 -2
  3. package/dist/{chunk-X4OB4DZ3.cjs → chunk-NYEPGZMP.cjs} +6 -2
  4. package/dist/chunk-NYEPGZMP.cjs.map +1 -0
  5. package/dist/{chunk-2JEY6TSO.js → chunk-WEW5LGZC.js} +6 -2
  6. package/dist/chunk-WEW5LGZC.js.map +1 -0
  7. package/dist/client/auth.d.ts +41 -41
  8. package/dist/client/index.cjs +0 -2
  9. package/dist/client/index.cjs.map +1 -1
  10. package/dist/client/index.d.ts +3 -1
  11. package/dist/client/index.d.ts.map +1 -1
  12. package/dist/client/index.js +0 -2
  13. package/dist/client/index.js.map +1 -1
  14. package/dist/client/types.d.ts +4 -0
  15. package/dist/client/types.d.ts.map +1 -1
  16. package/dist/db/schema/themes.d.ts +74 -0
  17. package/dist/db/schema/themes.d.ts.map +1 -1
  18. package/dist/index.cjs +930 -476
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.js +929 -475
  21. package/dist/index.js.map +1 -1
  22. package/dist/lib/middleware/turnstile-challenge.cjs +6 -6
  23. package/dist/lib/middleware/turnstile-challenge.d.ts.map +1 -1
  24. package/dist/lib/middleware/turnstile-challenge.js +1 -1
  25. package/dist/routes/health.d.ts.map +1 -1
  26. package/dist/routes/internal/batch-release.d.ts.map +1 -1
  27. package/dist/routes/internal/gforms-webhook.d.ts.map +1 -1
  28. package/dist/routes/internal/reconcile-slots.d.ts.map +1 -1
  29. package/dist/routes/internal/reminder-emails.d.ts.map +1 -1
  30. package/dist/routes/internal/renew-watches.d.ts.map +1 -1
  31. package/dist/routes/site-config.d.ts.map +1 -1
  32. package/dist/routes/themes.d.ts.map +1 -1
  33. package/dist/routes/uploads.d.ts.map +1 -1
  34. package/dist/routes/users.d.ts.map +1 -1
  35. package/dist/worker-handler.d.ts.map +1 -1
  36. package/dist/worker.js +927 -471
  37. package/dist/worker.js.map +1 -1
  38. package/package.json +4 -1
  39. package/dist/chunk-2JEY6TSO.js.map +0 -1
  40. package/dist/chunk-X4OB4DZ3.cjs.map +0 -1
package/dist/worker.js CHANGED
@@ -1,13 +1,14 @@
1
1
  import { Hono } from 'hono';
2
+ import { describeRoute, validator, openAPIRouteHandler } from 'hono-openapi';
3
+ import { swaggerUI } from '@hono/swagger-ui';
2
4
  import { cors } from 'hono/cors';
3
5
  import { drizzle } from 'drizzle-orm/d1';
4
6
  import { sqliteTable, integer, text, index, uniqueIndex } from 'drizzle-orm/sqlite-core';
5
- import { sql, relations, eq, and, count, isNotNull, lte } from 'drizzle-orm';
7
+ import { sql, relations, eq, and, asc, count, isNotNull, lte } from 'drizzle-orm';
6
8
  import { createMiddleware } from 'hono/factory';
7
9
  import { betterAuth } from 'better-auth';
8
10
  import { drizzleAdapter } from 'better-auth/adapters/drizzle';
9
11
  import { bearer } from 'better-auth/plugins';
10
- import { zValidator } from '@hono/zod-validator';
11
12
  import { z } from 'zod';
12
13
 
13
14
  var __defProp = Object.defineProperty;
@@ -24,8 +25,6 @@ var LeapifyError = class extends Error {
24
25
  this.code = code;
25
26
  this.name = "LeapifyError";
26
27
  }
27
- statusCode;
28
- code;
29
28
  };
30
29
  var unauthorized = (message = "Unauthorized") => new LeapifyError(401, "UNAUTHORIZED", message);
31
30
  var domainRestricted = () => new LeapifyError(
@@ -91,6 +90,10 @@ var themes = sqliteTable("themes", {
91
90
  name: text("name").notNull().unique(),
92
91
  path: text("path").notNull().unique(),
93
92
  // e.g. "/pirates-cove"
93
+ imageUrl: text("image_url"),
94
+ descriptionEn: text("description_en"),
95
+ descriptionFil: text("description_fil"),
96
+ sortOrder: integer("sort_order").notNull().default(0),
94
97
  createdAt: integer("created_at").notNull().$defaultFn(() => Math.floor(Date.now() / 1e3)),
95
98
  updatedAt: integer("updated_at").notNull().$defaultFn(() => Math.floor(Date.now() / 1e3))
96
99
  });
@@ -405,6 +408,10 @@ var EXEMPT_PATHS = [
405
408
  "/api/classes",
406
409
  "/api/faqs",
407
410
  "/api/config",
411
+ "/api/themes",
412
+ "/api/organizations",
413
+ "/api/docs",
414
+ "/api/openapi.json",
408
415
  TURNSTILE_VERIFY_PATH
409
416
  ];
410
417
  function base64urlEncode(bytes) {
@@ -734,8 +741,6 @@ var internalMiddleware = createMiddleware(async (c, next) => {
734
741
  }
735
742
  return next();
736
743
  });
737
-
738
- // src/routes/health.ts
739
744
  var healthRoute = new Hono();
740
745
  async function probeResend(apiKey) {
741
746
  const start = Date.now();
@@ -837,76 +842,93 @@ async function probeGForms(serviceAccountJson) {
837
842
  };
838
843
  }
839
844
  }
840
- healthRoute.get("/", async (c) => {
841
- const env = c.env;
842
- const hasSes = Boolean(env.SES_REGION) && Boolean(env.SES_ACCESS_KEY_ID) && Boolean(env.SES_SECRET_ACCESS_KEY);
843
- const hasResend = Boolean(env.RESEND_API_KEY);
844
- let hasGForms = false;
845
- if (env.GFORMS_SERVICE_ACCOUNT_JSON) {
846
- try {
847
- const parsed = JSON.parse(env.GFORMS_SERVICE_ACCOUNT_JSON);
848
- hasGForms = Boolean(parsed.client_email && parsed.private_key);
849
- } catch {
845
+ healthRoute.get(
846
+ "/",
847
+ describeRoute({
848
+ tags: ["Health"],
849
+ summary: "Service health check",
850
+ responses: { 200: { description: "Health status of configured services" } }
851
+ }),
852
+ async (c) => {
853
+ const env = c.env;
854
+ const hasSes = Boolean(env.SES_REGION) && Boolean(env.SES_ACCESS_KEY_ID) && Boolean(env.SES_SECRET_ACCESS_KEY);
855
+ const hasResend = Boolean(env.RESEND_API_KEY);
856
+ let hasGForms = false;
857
+ if (env.GFORMS_SERVICE_ACCOUNT_JSON) {
858
+ try {
859
+ const parsed = JSON.parse(env.GFORMS_SERVICE_ACCOUNT_JSON);
860
+ hasGForms = Boolean(parsed.client_email && parsed.private_key);
861
+ } catch {
862
+ }
850
863
  }
851
- }
852
- const probes = [];
853
- if (hasSes) {
854
- probes.push(
855
- probeSes(env.SES_REGION, env.SES_ACCESS_KEY_ID, env.SES_SECRET_ACCESS_KEY).then(
856
- (h) => ["ses", h]
857
- )
858
- );
859
- }
860
- if (hasResend) {
861
- probes.push(
862
- probeResend(env.RESEND_API_KEY).then((h) => ["resend", h])
863
- );
864
- }
865
- if (hasGForms) {
866
- probes.push(
867
- probeGForms(env.GFORMS_SERVICE_ACCOUNT_JSON).then(
868
- (h) => ["gforms", h]
869
- )
870
- );
871
- }
872
- const results = await Promise.all(probes);
873
- const services = {};
874
- for (const [name, health] of results) {
875
- services[name] = health;
876
- }
877
- const allOk = results.length === 0 || results.every(([, h]) => h.ok);
878
- return c.json({
879
- data: {
880
- status: allOk ? "OK" : "DEGRADED",
881
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
882
- services
864
+ const probes = [];
865
+ if (hasSes) {
866
+ probes.push(
867
+ probeSes(env.SES_REGION, env.SES_ACCESS_KEY_ID, env.SES_SECRET_ACCESS_KEY).then(
868
+ (h) => ["ses", h]
869
+ )
870
+ );
883
871
  }
884
- });
885
- });
886
- healthRoute.post("/queue-burst", authMiddleware, adminMiddleware, async (c) => {
887
- if (!c.env.EMAIL_QUEUE) {
888
- return c.json({ error: "Queue binding missing" }, 400);
889
- }
890
- const batch = Array.from({ length: 100 }, (_, i) => ({
891
- body: {
892
- type: "audit_log",
893
- payload: {
894
- action: "queue_load_test",
895
- userId: "system",
896
- meta: { index: i, time: Date.now() }
872
+ if (hasResend) {
873
+ probes.push(
874
+ probeResend(env.RESEND_API_KEY).then((h) => ["resend", h])
875
+ );
876
+ }
877
+ if (hasGForms) {
878
+ probes.push(
879
+ probeGForms(env.GFORMS_SERVICE_ACCOUNT_JSON).then(
880
+ (h) => ["gforms", h]
881
+ )
882
+ );
883
+ }
884
+ const results = await Promise.all(probes);
885
+ const services = {};
886
+ for (const [name, health] of results) {
887
+ services[name] = health;
888
+ }
889
+ const allOk = results.length === 0 || results.every(([, h]) => h.ok);
890
+ return c.json({
891
+ data: {
892
+ status: allOk ? "OK" : "DEGRADED",
893
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
894
+ services
897
895
  }
896
+ });
897
+ }
898
+ );
899
+ healthRoute.post(
900
+ "/queue-burst",
901
+ describeRoute({
902
+ tags: ["Health"],
903
+ summary: "Queue load test (admin)",
904
+ responses: { 200: { description: "Items queued" } }
905
+ }),
906
+ authMiddleware,
907
+ adminMiddleware,
908
+ async (c) => {
909
+ if (!c.env.EMAIL_QUEUE) {
910
+ return c.json({ error: "Queue binding missing" }, 400);
898
911
  }
899
- }));
900
- await c.env.EMAIL_QUEUE.sendBatch(batch);
901
- return c.json({ status: "queued", count: 100 });
902
- });
912
+ const batch = Array.from({ length: 100 }, (_, i) => ({
913
+ body: {
914
+ type: "audit_log",
915
+ payload: {
916
+ action: "queue_load_test",
917
+ userId: "system",
918
+ meta: { index: i, time: Date.now() }
919
+ }
920
+ }
921
+ }));
922
+ await c.env.EMAIL_QUEUE.sendBatch(batch);
923
+ return c.json({ status: "queued", count: 100 });
924
+ }
925
+ );
903
926
 
904
927
  // src/services/cache.ts
905
928
  var CacheService = class {
906
929
  constructor(kv) {
907
930
  this.kv = kv;
908
931
  }
909
- kv;
910
932
  async get(key) {
911
933
  return this.kv.get(key, "json");
912
934
  }
@@ -952,8 +974,6 @@ var SlotsService = class {
952
974
  this.db = db;
953
975
  this.cache = cache;
954
976
  }
955
- db;
956
- cache;
957
977
  kvKey(slug) {
958
978
  return `${SLOT_KV_PREFIX}${slug}`;
959
979
  }
@@ -1275,143 +1295,221 @@ var classesRoute = new Hono();
1275
1295
  function generateSlug(title) {
1276
1296
  return title.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
1277
1297
  }
1278
- classesRoute.get("/admin", authMiddleware, adminMiddleware, async (c) => {
1279
- const db = createDb(c.env.DB);
1280
- const data = await db.query.events.findMany({
1281
- with: { theme: true, organization: true },
1282
- orderBy: (e, { desc }) => [desc(e.createdAt)]
1283
- });
1284
- return c.json({ data });
1285
- });
1286
- classesRoute.post("/admin/publish", authMiddleware, adminMiddleware, async (c) => {
1287
- const body = await c.req.json();
1288
- const db = createDb(c.env.DB);
1289
- const cache = new CacheService(c.env.KV);
1290
- if (!body.ids?.length) {
1291
- return c.json({ error: "ids are required" }, 400);
1292
- }
1293
- if (body.releaseAt) {
1294
- await db.update(events).set({ releaseAt: body.releaseAt, status: "queued" }).where(
1295
- sql`${events.id} IN (${sql.join(
1296
- body.ids.map((id) => sql`${id}`),
1297
- sql`, `
1298
- )})`
1298
+ classesRoute.get(
1299
+ "/admin",
1300
+ describeRoute({
1301
+ tags: ["Events"],
1302
+ summary: "List all events (admin)",
1303
+ responses: { 200: { description: "List of all events" } }
1304
+ }),
1305
+ authMiddleware,
1306
+ adminMiddleware,
1307
+ async (c) => {
1308
+ const db = createDb(c.env.DB);
1309
+ const data = await db.query.events.findMany({
1310
+ with: { theme: true, organization: true },
1311
+ orderBy: (e, { desc }) => [desc(e.createdAt)]
1312
+ });
1313
+ return c.json({ data });
1314
+ }
1315
+ );
1316
+ classesRoute.post(
1317
+ "/admin/publish",
1318
+ describeRoute({
1319
+ tags: ["Events"],
1320
+ summary: "Batch publish queued events",
1321
+ responses: {
1322
+ 200: { description: "Events published successfully" },
1323
+ 400: { description: "Missing event IDs" }
1324
+ }
1325
+ }),
1326
+ authMiddleware,
1327
+ adminMiddleware,
1328
+ async (c) => {
1329
+ const body = await c.req.json();
1330
+ const db = createDb(c.env.DB);
1331
+ const cache = new CacheService(c.env.KV);
1332
+ if (!body.ids?.length) {
1333
+ return c.json({ error: "ids are required" }, 400);
1334
+ }
1335
+ if (body.releaseAt) {
1336
+ await db.update(events).set({ releaseAt: body.releaseAt, status: "queued" }).where(
1337
+ sql`${events.id} IN (${sql.join(
1338
+ body.ids.map((id) => sql`${id}`),
1339
+ sql`, `
1340
+ )})`
1341
+ );
1342
+ } else {
1343
+ await db.update(events).set({ status: "published", publishedAt: sql`(unixepoch())` }).where(
1344
+ sql`${events.id} IN (${sql.join(
1345
+ body.ids.map((id) => sql`${id}`),
1346
+ sql`, `
1347
+ )})`
1348
+ );
1349
+ }
1350
+ await Promise.all([
1351
+ cache.del(EVENTS_LIST_KV_KEY),
1352
+ cache.del(EVENTS_ETAG_KV_KEY)
1353
+ ]);
1354
+ return c.json({ data: { updated: body.ids.length } });
1355
+ }
1356
+ );
1357
+ classesRoute.get(
1358
+ "/",
1359
+ describeRoute({
1360
+ tags: ["Events"],
1361
+ summary: "List published events",
1362
+ responses: { 200: { description: "List of published events with themes" } }
1363
+ }),
1364
+ eventsListRateLimit,
1365
+ async (c) => {
1366
+ const db = createDb(c.env.DB);
1367
+ const cache = new CacheService(c.env.KV);
1368
+ const [latest] = await db.select({ max: events.publishedAt }).from(events).where(eq(events.status, "published")).limit(1);
1369
+ const etag = await cache.getOrSet(
1370
+ EVENTS_ETAG_KV_KEY,
1371
+ () => cache.generateETag(String(latest?.max ?? "0")),
1372
+ 300
1373
+ );
1374
+ const ifNoneMatch = c.req.header("If-None-Match");
1375
+ if (ifNoneMatch === etag) {
1376
+ return c.body(null, 304);
1377
+ }
1378
+ const data = await cache.getOrSet(
1379
+ EVENTS_LIST_KV_KEY,
1380
+ () => db.query.events.findMany({
1381
+ where: eq(events.status, "published"),
1382
+ with: {
1383
+ theme: true,
1384
+ organization: true
1385
+ },
1386
+ columns: {
1387
+ id: true,
1388
+ slug: true,
1389
+ themeId: true,
1390
+ organizationId: true,
1391
+ title: true,
1392
+ venue: true,
1393
+ dateTime: true,
1394
+ price: true,
1395
+ backgroundImageUrl: true,
1396
+ classCode: true,
1397
+ startTime: true,
1398
+ endTime: true,
1399
+ registrationClosesAt: true,
1400
+ isSpotlight: true,
1401
+ maxSlots: true,
1402
+ registeredSlots: true,
1403
+ gformsUrl: true,
1404
+ gformsEditorUrl: true,
1405
+ publishedAt: true
1406
+ }
1407
+ }),
1408
+ EVENTS_LIST_TTL
1299
1409
  );
1300
- } else {
1301
- await db.update(events).set({ status: "published", publishedAt: sql`(unixepoch())` }).where(
1302
- sql`${events.id} IN (${sql.join(
1303
- body.ids.map((id) => sql`${id}`),
1304
- sql`, `
1305
- )})`
1410
+ c.header("ETag", etag);
1411
+ c.header(
1412
+ "Cache-Control",
1413
+ "public, max-age=604800, stale-while-revalidate=86400"
1306
1414
  );
1415
+ return c.json({ data });
1307
1416
  }
1308
- await Promise.all([
1309
- cache.del(EVENTS_LIST_KV_KEY),
1310
- cache.del(EVENTS_ETAG_KV_KEY)
1311
- ]);
1312
- return c.json({ data: { updated: body.ids.length } });
1313
- });
1314
- classesRoute.get("/", eventsListRateLimit, async (c) => {
1315
- const db = createDb(c.env.DB);
1316
- const cache = new CacheService(c.env.KV);
1317
- const [latest] = await db.select({ max: events.publishedAt }).from(events).where(eq(events.status, "published")).limit(1);
1318
- const etag = await cache.getOrSet(
1319
- EVENTS_ETAG_KV_KEY,
1320
- () => cache.generateETag(String(latest?.max ?? "0")),
1321
- 300
1322
- );
1323
- const ifNoneMatch = c.req.header("If-None-Match");
1324
- if (ifNoneMatch === etag) {
1325
- return c.body(null, 304);
1326
- }
1327
- const data = await cache.getOrSet(
1328
- EVENTS_LIST_KV_KEY,
1329
- () => db.query.events.findMany({
1330
- where: eq(events.status, "published"),
1417
+ );
1418
+ classesRoute.get(
1419
+ "/:slug",
1420
+ describeRoute({
1421
+ tags: ["Events"],
1422
+ summary: "Get event by slug",
1423
+ responses: {
1424
+ 200: { description: "Event details" },
1425
+ 404: { description: "Event not found" }
1426
+ }
1427
+ }),
1428
+ async (c) => {
1429
+ const { slug } = c.req.param();
1430
+ const db = createDb(c.env.DB);
1431
+ const event = await db.query.events.findFirst({
1432
+ where: and(eq(events.slug, slug), eq(events.status, "published")),
1331
1433
  with: {
1332
- theme: true,
1333
- organization: true
1334
- },
1335
- columns: {
1336
- id: true,
1337
- slug: true,
1338
- themeId: true,
1339
- organizationId: true,
1340
- title: true,
1341
- venue: true,
1342
- dateTime: true,
1343
- price: true,
1344
- backgroundImageUrl: true,
1345
- classCode: true,
1346
- startTime: true,
1347
- endTime: true,
1348
- registrationClosesAt: true,
1349
- isSpotlight: true,
1350
- maxSlots: true,
1351
- registeredSlots: true,
1352
- gformsUrl: true,
1353
- gformsEditorUrl: true,
1354
- publishedAt: true
1434
+ theme: true
1355
1435
  }
1356
- }),
1357
- EVENTS_LIST_TTL
1358
- );
1359
- c.header("ETag", etag);
1360
- c.header(
1361
- "Cache-Control",
1362
- "public, max-age=604800, stale-while-revalidate=86400"
1363
- );
1364
- return c.json({ data });
1365
- });
1366
- classesRoute.get("/:slug", async (c) => {
1367
- const { slug } = c.req.param();
1368
- const db = createDb(c.env.DB);
1369
- const event = await db.query.events.findFirst({
1370
- where: and(eq(events.slug, slug), eq(events.status, "published")),
1371
- with: {
1372
- theme: true
1436
+ });
1437
+ if (!event) throw notFound("Event");
1438
+ return c.json({ data: event });
1439
+ }
1440
+ );
1441
+ classesRoute.get(
1442
+ "/:slug/slots",
1443
+ describeRoute({
1444
+ tags: ["Events"],
1445
+ summary: "Get event slot availability",
1446
+ responses: {
1447
+ 200: { description: "Slot availability info" },
1448
+ 404: { description: "Event not found" }
1373
1449
  }
1374
- });
1375
- if (!event) throw notFound("Event");
1376
- return c.json({ data: event });
1377
- });
1378
- classesRoute.get("/:slug/slots", eventsSlotsRateLimit, async (c) => {
1379
- const { slug } = c.req.param();
1380
- const db = createDb(c.env.DB);
1381
- const cache = new CacheService(c.env.KV);
1382
- const slotsService = new SlotsService(db, cache);
1383
- const info = await slotsService.getSlots(slug);
1384
- if (!info) throw notFound("Event");
1385
- c.header("Cache-Control", "public, max-age=5, stale-while-revalidate=5");
1386
- return c.json({ data: info });
1387
- });
1388
- classesRoute.post("/:slug/reconcile", authMiddleware, adminMiddleware, async (c) => {
1389
- const { slug } = c.req.param();
1390
- const db = createDb(c.env.DB);
1391
- const cache = new CacheService(c.env.KV);
1392
- const gforms = new GFormsService(c.env.GFORMS_SERVICE_ACCOUNT_JSON);
1393
- const slots = new SlotsService(db, cache);
1394
- const event = await db.query.events.findFirst({
1395
- where: eq(events.slug, slug),
1396
- columns: { gformsId: true }
1397
- });
1398
- if (!event) throw notFound("Event");
1399
- if (!event.gformsId) return c.json({ error: "No gformsId set for this event" }, 400);
1400
- try {
1401
- const googleCount = await gforms.getExactResponseCount(event.gformsId);
1402
- await slots.correctCount(slug, googleCount);
1403
- return c.json({ data: { registeredSlots: googleCount } });
1404
- } catch (err) {
1405
- const message = err?.message ?? "Failed to fetch from Google Forms API";
1406
- return c.json({ error: { code: "GFORMS_API_ERROR", message } }, 502);
1450
+ }),
1451
+ eventsSlotsRateLimit,
1452
+ async (c) => {
1453
+ const { slug } = c.req.param();
1454
+ const db = createDb(c.env.DB);
1455
+ const cache = new CacheService(c.env.KV);
1456
+ const slotsService = new SlotsService(db, cache);
1457
+ const info = await slotsService.getSlots(slug);
1458
+ if (!info) throw notFound("Event");
1459
+ c.header("Cache-Control", "public, max-age=5, stale-while-revalidate=5");
1460
+ return c.json({ data: info });
1407
1461
  }
1408
- });
1462
+ );
1463
+ classesRoute.post(
1464
+ "/:slug/reconcile",
1465
+ describeRoute({
1466
+ tags: ["Events"],
1467
+ summary: "Reconcile event slot count with Google Forms",
1468
+ responses: {
1469
+ 200: { description: "Slot count reconciled" },
1470
+ 400: { description: "No gformsId set" },
1471
+ 404: { description: "Event not found" },
1472
+ 502: { description: "Google Forms API error" }
1473
+ }
1474
+ }),
1475
+ authMiddleware,
1476
+ adminMiddleware,
1477
+ async (c) => {
1478
+ const { slug } = c.req.param();
1479
+ const db = createDb(c.env.DB);
1480
+ const cache = new CacheService(c.env.KV);
1481
+ const gforms = new GFormsService(c.env.GFORMS_SERVICE_ACCOUNT_JSON);
1482
+ const slots = new SlotsService(db, cache);
1483
+ const event = await db.query.events.findFirst({
1484
+ where: eq(events.slug, slug),
1485
+ columns: { gformsId: true }
1486
+ });
1487
+ if (!event) throw notFound("Event");
1488
+ if (!event.gformsId) return c.json({ error: "No gformsId set for this event" }, 400);
1489
+ try {
1490
+ const googleCount = await gforms.getExactResponseCount(event.gformsId);
1491
+ await slots.correctCount(slug, googleCount);
1492
+ return c.json({ data: { registeredSlots: googleCount } });
1493
+ } catch (err) {
1494
+ const message = err?.message ?? "Failed to fetch from Google Forms API";
1495
+ return c.json({ error: { code: "GFORMS_API_ERROR", message } }, 502);
1496
+ }
1497
+ }
1498
+ );
1409
1499
  classesRoute.post(
1410
1500
  "/",
1501
+ describeRoute({
1502
+ tags: ["Events"],
1503
+ summary: "Create a new event",
1504
+ responses: {
1505
+ 201: { description: "Event created successfully" },
1506
+ 422: { description: "Validation error" }
1507
+ }
1508
+ }),
1411
1509
  authMiddleware,
1412
1510
  adminMiddleware,
1413
1511
  adminEventsRateLimit,
1414
- zValidator("json", createEventSchema),
1512
+ validator("json", createEventSchema),
1415
1513
  async (c) => {
1416
1514
  const body = c.req.valid("json");
1417
1515
  const db = createDb(c.env.DB);
@@ -1444,158 +1542,284 @@ classesRoute.post(
1444
1542
  return c.json({ data: created }, 201);
1445
1543
  }
1446
1544
  );
1447
- classesRoute.patch("/:slug", authMiddleware, adminMiddleware, async (c) => {
1448
- const { slug } = c.req.param();
1449
- const body = await c.req.json();
1450
- const db = createDb(c.env.DB);
1451
- const cache = new CacheService(c.env.KV);
1452
- let newSlug;
1453
- if (body.title) {
1454
- newSlug = generateSlug(body.title);
1455
- }
1456
- const [updated] = await db.update(events).set(newSlug ? { ...body, slug: newSlug } : body).where(eq(events.slug, slug)).returning();
1457
- if (!updated) throw notFound("Event");
1458
- await Promise.all([
1459
- cache.del(EVENTS_LIST_KV_KEY),
1460
- cache.del(EVENTS_ETAG_KV_KEY)
1461
- ]);
1462
- return c.json({ data: updated });
1463
- });
1464
- classesRoute.delete("/:slug", authMiddleware, adminMiddleware, async (c) => {
1465
- const { slug } = c.req.param();
1466
- const db = createDb(c.env.DB);
1467
- const cache = new CacheService(c.env.KV);
1468
- const [deleted] = await db.delete(events).where(eq(events.slug, slug)).returning();
1469
- if (!deleted) throw notFound("Event");
1470
- await Promise.all([
1471
- cache.del(EVENTS_LIST_KV_KEY),
1472
- cache.del(EVENTS_ETAG_KV_KEY)
1473
- ]);
1474
- return c.body(null, 204);
1475
- });
1545
+ classesRoute.patch(
1546
+ "/:slug",
1547
+ describeRoute({
1548
+ tags: ["Events"],
1549
+ summary: "Update an event",
1550
+ responses: {
1551
+ 200: { description: "Event updated successfully" },
1552
+ 404: { description: "Event not found" }
1553
+ }
1554
+ }),
1555
+ authMiddleware,
1556
+ adminMiddleware,
1557
+ async (c) => {
1558
+ const { slug } = c.req.param();
1559
+ const body = await c.req.json();
1560
+ const db = createDb(c.env.DB);
1561
+ const cache = new CacheService(c.env.KV);
1562
+ let newSlug;
1563
+ if (body.title) {
1564
+ newSlug = generateSlug(body.title);
1565
+ }
1566
+ const [updated] = await db.update(events).set(newSlug ? { ...body, slug: newSlug } : body).where(eq(events.slug, slug)).returning();
1567
+ if (!updated) throw notFound("Event");
1568
+ await Promise.all([
1569
+ cache.del(EVENTS_LIST_KV_KEY),
1570
+ cache.del(EVENTS_ETAG_KV_KEY)
1571
+ ]);
1572
+ return c.json({ data: updated });
1573
+ }
1574
+ );
1575
+ classesRoute.delete(
1576
+ "/:slug",
1577
+ describeRoute({
1578
+ tags: ["Events"],
1579
+ summary: "Delete an event",
1580
+ responses: {
1581
+ 204: { description: "Event deleted" },
1582
+ 404: { description: "Event not found" }
1583
+ }
1584
+ }),
1585
+ authMiddleware,
1586
+ adminMiddleware,
1587
+ async (c) => {
1588
+ const { slug } = c.req.param();
1589
+ const db = createDb(c.env.DB);
1590
+ const cache = new CacheService(c.env.KV);
1591
+ const [deleted] = await db.delete(events).where(eq(events.slug, slug)).returning();
1592
+ if (!deleted) throw notFound("Event");
1593
+ await Promise.all([
1594
+ cache.del(EVENTS_LIST_KV_KEY),
1595
+ cache.del(EVENTS_ETAG_KV_KEY)
1596
+ ]);
1597
+ return c.body(null, 204);
1598
+ }
1599
+ );
1476
1600
  var VALID_ROLES = ["student", "admin", "super_admin"];
1477
1601
  var usersRoute = new Hono();
1478
- usersRoute.get("/", authMiddleware, adminMiddleware, async (c) => {
1479
- const db = createDb(c.env.DB);
1480
- const data = await db.select().from(users);
1481
- return c.json({ data });
1482
- });
1483
- usersRoute.patch("/:id/role", authMiddleware, adminMiddleware, async (c) => {
1484
- const { id } = c.req.param();
1485
- const { role } = await c.req.json();
1486
- if (!role || !VALID_ROLES.includes(role)) {
1487
- throw badRequest("Role must be 'student', 'admin', or 'super_admin'.");
1488
- }
1489
- const db = createDb(c.env.DB);
1490
- const [updated] = await db.update(users).set({ role }).where(eq(users.id, id)).returning();
1491
- if (!updated) throw notFound("User");
1492
- return c.json({ data: updated });
1493
- });
1494
- usersRoute.post("/by-email", authMiddleware, adminMiddleware, async (c) => {
1495
- const { email, role } = await c.req.json();
1496
- if (!email || !role || !VALID_ROLES.includes(role)) {
1497
- throw badRequest("Email and valid role ('student', 'admin', 'super_admin') are required.");
1498
- }
1499
- const db = createDb(c.env.DB);
1500
- const existing = await db.query.users.findFirst({
1501
- where: eq(users.email, email)
1502
- });
1503
- if (existing) {
1504
- const [updated] = await db.update(users).set({ role }).where(eq(users.email, email)).returning();
1602
+ usersRoute.get(
1603
+ "/",
1604
+ describeRoute({
1605
+ tags: ["Users"],
1606
+ summary: "List all users (admin)",
1607
+ responses: { 200: { description: "List of users" } }
1608
+ }),
1609
+ authMiddleware,
1610
+ adminMiddleware,
1611
+ async (c) => {
1612
+ const db = createDb(c.env.DB);
1613
+ const data = await db.select().from(users);
1614
+ return c.json({ data });
1615
+ }
1616
+ );
1617
+ usersRoute.patch(
1618
+ "/:id/role",
1619
+ describeRoute({
1620
+ tags: ["Users"],
1621
+ summary: "Change user role (admin)",
1622
+ responses: {
1623
+ 200: { description: "Role updated" },
1624
+ 400: { description: "Invalid role" },
1625
+ 404: { description: "User not found" }
1626
+ }
1627
+ }),
1628
+ authMiddleware,
1629
+ adminMiddleware,
1630
+ async (c) => {
1631
+ const { id } = c.req.param();
1632
+ const { role } = await c.req.json();
1633
+ if (!role || !VALID_ROLES.includes(role)) {
1634
+ throw badRequest("Role must be 'student', 'admin', or 'super_admin'.");
1635
+ }
1636
+ const db = createDb(c.env.DB);
1637
+ const [updated] = await db.update(users).set({ role }).where(eq(users.id, id)).returning();
1638
+ if (!updated) throw notFound("User");
1505
1639
  return c.json({ data: updated });
1506
1640
  }
1507
- const [created] = await db.insert(users).values({ betterAuthId: `pending:${email}`, email, name: email.split("@")[0], role }).returning();
1508
- return c.json({ data: created }, 201);
1509
- });
1510
- usersRoute.get("/me", optionalAuthMiddleware, async (c) => {
1511
- const user = c.get("user");
1512
- if (!user) return c.json({ data: null });
1513
- const db = createDb(c.env.DB);
1514
- const profile = await db.query.users.findFirst({
1515
- where: eq(users.id, user.dbId)
1516
- });
1517
- if (!profile) return c.json({ data: null });
1518
- const auth = await db.query.authUser.findFirst({
1519
- where: eq(authUser.id, profile.betterAuthId),
1520
- columns: { image: true }
1521
- });
1522
- return c.json({ data: { ...profile, image: auth?.image ?? null } });
1523
- });
1524
- usersRoute.get("/me/bookmarks", optionalAuthMiddleware, async (c) => {
1525
- const user = c.get("user");
1526
- if (!user) return c.json({ data: [] });
1527
- const db = createDb(c.env.DB);
1528
- const rows = await db.query.bookmarks.findMany({
1529
- where: eq(bookmarks.userId, user.dbId),
1530
- with: { event: true }
1531
- });
1532
- const data = rows.map((r) => ({ bookmarkedAt: r.createdAt, event: r.event }));
1533
- return c.json({ data });
1534
- });
1535
- usersRoute.post("/me/bookmarks/:eventId", authMiddleware, bookmarksRateLimit, async (c) => {
1536
- const { eventId } = c.req.param();
1537
- const user = c.get("user");
1538
- const db = createDb(c.env.DB);
1539
- const event = await db.query.events.findFirst({
1540
- where: eq(events.id, eventId),
1541
- columns: { id: true }
1542
- });
1543
- if (!event) throw notFound("Event");
1544
- const inserted = await db.insert(bookmarks).values({ userId: user.dbId, eventId }).onConflictDoNothing({ target: [bookmarks.userId, bookmarks.eventId] }).returning();
1545
- if (inserted.length > 0) {
1546
- return c.json({ data: { bookmarked: true } }, 201);
1641
+ );
1642
+ usersRoute.post(
1643
+ "/by-email",
1644
+ describeRoute({
1645
+ tags: ["Users"],
1646
+ summary: "Find or create user by email (admin)",
1647
+ responses: {
1648
+ 200: { description: "User updated" },
1649
+ 201: { description: "User created" },
1650
+ 400: { description: "Invalid email or role" }
1651
+ }
1652
+ }),
1653
+ authMiddleware,
1654
+ adminMiddleware,
1655
+ async (c) => {
1656
+ const { email, role } = await c.req.json();
1657
+ if (!email || !role || !VALID_ROLES.includes(role)) {
1658
+ throw badRequest("Email and valid role ('student', 'admin', 'super_admin') are required.");
1659
+ }
1660
+ const db = createDb(c.env.DB);
1661
+ const existing = await db.query.users.findFirst({
1662
+ where: eq(users.email, email)
1663
+ });
1664
+ if (existing) {
1665
+ const [updated] = await db.update(users).set({ role }).where(eq(users.email, email)).returning();
1666
+ return c.json({ data: updated });
1667
+ }
1668
+ const [created] = await db.insert(users).values({ betterAuthId: `pending:${email}`, email, name: email.split("@")[0], role }).returning();
1669
+ return c.json({ data: created }, 201);
1547
1670
  }
1548
- await db.delete(bookmarks).where(and(eq(bookmarks.userId, user.dbId), eq(bookmarks.eventId, eventId)));
1549
- return c.json({ data: { bookmarked: false } }, 200);
1550
- });
1551
- usersRoute.delete("/me/bookmarks/:eventId", authMiddleware, async (c) => {
1552
- const { eventId } = c.req.param();
1553
- const user = c.get("user");
1554
- const db = createDb(c.env.DB);
1555
- await db.delete(bookmarks).where(
1556
- and(eq(bookmarks.userId, user.dbId), eq(bookmarks.eventId, eventId))
1557
- );
1558
- return c.json({ data: { bookmarked: false } });
1559
- });
1560
- var siteConfigRoute = new Hono();
1561
- siteConfigRoute.get("/", async (c) => {
1562
- const db = createDb(c.env.DB);
1563
- const rows = await db.query.siteConfig.findMany();
1564
- const config = Object.fromEntries(
1565
- rows.map((r) => [r.key, JSON.parse(r.value)])
1566
- );
1567
- return c.json({
1568
- data: {
1569
- comingSoonUntil: config.coming_soon_until ?? null,
1570
- siteEndsAt: config.site_ends_at ?? null,
1571
- siteName: config.site_name ?? null,
1572
- registrationGloballyOpen: config.registration_globally_open ?? true,
1573
- maintenanceMode: config.maintenance_mode ?? false,
1574
- allowedOrigins: config.allowed_origins ?? null,
1575
- now: Math.floor(Date.now() / 1e3)
1671
+ );
1672
+ usersRoute.get(
1673
+ "/me",
1674
+ describeRoute({
1675
+ tags: ["Users"],
1676
+ summary: "Get current user profile",
1677
+ responses: { 200: { description: "Current user profile or null" } }
1678
+ }),
1679
+ optionalAuthMiddleware,
1680
+ async (c) => {
1681
+ const user = c.get("user");
1682
+ if (!user) return c.json({ data: null });
1683
+ const db = createDb(c.env.DB);
1684
+ const profile = await db.query.users.findFirst({
1685
+ where: eq(users.id, user.dbId)
1686
+ });
1687
+ if (!profile) return c.json({ data: null });
1688
+ const auth = await db.query.authUser.findFirst({
1689
+ where: eq(authUser.id, profile.betterAuthId),
1690
+ columns: { image: true }
1691
+ });
1692
+ return c.json({ data: { ...profile, image: auth?.image ?? null } });
1693
+ }
1694
+ );
1695
+ usersRoute.get(
1696
+ "/me/bookmarks",
1697
+ describeRoute({
1698
+ tags: ["Users"],
1699
+ summary: "Get current user's bookmarks",
1700
+ responses: { 200: { description: "List of bookmarked events" } }
1701
+ }),
1702
+ optionalAuthMiddleware,
1703
+ async (c) => {
1704
+ const user = c.get("user");
1705
+ if (!user) return c.json({ data: [] });
1706
+ const db = createDb(c.env.DB);
1707
+ const rows = await db.query.bookmarks.findMany({
1708
+ where: eq(bookmarks.userId, user.dbId),
1709
+ with: { event: true }
1710
+ });
1711
+ const data = rows.map((r) => ({ bookmarkedAt: r.createdAt, event: r.event }));
1712
+ return c.json({ data });
1713
+ }
1714
+ );
1715
+ usersRoute.post(
1716
+ "/me/bookmarks/:eventId",
1717
+ describeRoute({
1718
+ tags: ["Users"],
1719
+ summary: "Toggle bookmark for an event",
1720
+ responses: {
1721
+ 201: { description: "Bookmark created" },
1722
+ 200: { description: "Bookmark removed" },
1723
+ 404: { description: "Event not found" }
1576
1724
  }
1577
- });
1578
- });
1579
- siteConfigRoute.patch("/:key", authMiddleware, adminMiddleware, async (c) => {
1580
- const key = c.req.param("key");
1581
- const { value } = await c.req.json();
1582
- if (key === "allowed_origins") {
1725
+ }),
1726
+ authMiddleware,
1727
+ bookmarksRateLimit,
1728
+ async (c) => {
1729
+ const { eventId } = c.req.param();
1583
1730
  const user = c.get("user");
1584
- if (!user || user.role !== "super_admin") {
1585
- throw forbidden("Super Admin access required to change allowed origins");
1731
+ const db = createDb(c.env.DB);
1732
+ const event = await db.query.events.findFirst({
1733
+ where: eq(events.id, eventId),
1734
+ columns: { id: true }
1735
+ });
1736
+ if (!event) throw notFound("Event");
1737
+ const inserted = await db.insert(bookmarks).values({ userId: user.dbId, eventId }).onConflictDoNothing({ target: [bookmarks.userId, bookmarks.eventId] }).returning();
1738
+ if (inserted.length > 0) {
1739
+ return c.json({ data: { bookmarked: true } }, 201);
1586
1740
  }
1741
+ await db.delete(bookmarks).where(and(eq(bookmarks.userId, user.dbId), eq(bookmarks.eventId, eventId)));
1742
+ return c.json({ data: { bookmarked: false } }, 200);
1587
1743
  }
1588
- const db = createDb(c.env.DB);
1589
- const now = Math.floor(Date.now() / 1e3);
1590
- await db.insert(siteConfig).values({ key, value: JSON.stringify(value), updatedAt: now }).onConflictDoUpdate({
1591
- target: siteConfig.key,
1592
- set: { value: JSON.stringify(value), updatedAt: now }
1593
- });
1594
- await c.env.KV.put(`config:${key}`, JSON.stringify(value), {
1595
- expirationTtl: 86400
1596
- });
1597
- return c.json({ data: { key, value } });
1598
- });
1744
+ );
1745
+ usersRoute.delete(
1746
+ "/me/bookmarks/:eventId",
1747
+ describeRoute({
1748
+ tags: ["Users"],
1749
+ summary: "Remove bookmark for an event",
1750
+ responses: { 200: { description: "Bookmark removed" } }
1751
+ }),
1752
+ authMiddleware,
1753
+ async (c) => {
1754
+ const { eventId } = c.req.param();
1755
+ const user = c.get("user");
1756
+ const db = createDb(c.env.DB);
1757
+ await db.delete(bookmarks).where(
1758
+ and(eq(bookmarks.userId, user.dbId), eq(bookmarks.eventId, eventId))
1759
+ );
1760
+ return c.json({ data: { bookmarked: false } });
1761
+ }
1762
+ );
1763
+ var siteConfigRoute = new Hono();
1764
+ siteConfigRoute.get(
1765
+ "/",
1766
+ describeRoute({
1767
+ tags: ["Site Config"],
1768
+ summary: "Get public site configuration",
1769
+ responses: { 200: { description: "Site configuration values" } }
1770
+ }),
1771
+ async (c) => {
1772
+ const db = createDb(c.env.DB);
1773
+ const rows = await db.query.siteConfig.findMany();
1774
+ const config = Object.fromEntries(
1775
+ rows.map((r) => [r.key, JSON.parse(r.value)])
1776
+ );
1777
+ return c.json({
1778
+ data: {
1779
+ comingSoonUntil: config.coming_soon_until ?? null,
1780
+ siteEndsAt: config.site_ends_at ?? null,
1781
+ siteName: config.site_name ?? null,
1782
+ registrationGloballyOpen: config.registration_globally_open ?? true,
1783
+ maintenanceMode: config.maintenance_mode ?? false,
1784
+ allowedOrigins: config.allowed_origins ?? null,
1785
+ now: Math.floor(Date.now() / 1e3)
1786
+ }
1787
+ });
1788
+ }
1789
+ );
1790
+ siteConfigRoute.patch(
1791
+ "/:key",
1792
+ describeRoute({
1793
+ tags: ["Site Config"],
1794
+ summary: "Update a site configuration key (admin)",
1795
+ responses: {
1796
+ 200: { description: "Config updated" },
1797
+ 403: { description: "Super admin required for this key" }
1798
+ }
1799
+ }),
1800
+ authMiddleware,
1801
+ adminMiddleware,
1802
+ async (c) => {
1803
+ const key = c.req.param("key");
1804
+ const { value } = await c.req.json();
1805
+ if (key === "allowed_origins") {
1806
+ const user = c.get("user");
1807
+ if (!user || user.role !== "super_admin") {
1808
+ throw forbidden("Super Admin access required to change allowed origins");
1809
+ }
1810
+ }
1811
+ const db = createDb(c.env.DB);
1812
+ const now = Math.floor(Date.now() / 1e3);
1813
+ await db.insert(siteConfig).values({ key, value: JSON.stringify(value), updatedAt: now }).onConflictDoUpdate({
1814
+ target: siteConfig.key,
1815
+ set: { value: JSON.stringify(value), updatedAt: now }
1816
+ });
1817
+ await c.env.KV.put(`config:${key}`, JSON.stringify(value), {
1818
+ expirationTtl: 86400
1819
+ });
1820
+ return c.json({ data: { key, value } });
1821
+ }
1822
+ );
1599
1823
  var FAQS_KV_KEY = "faqs:active";
1600
1824
  var FAQS_TTL = 600;
1601
1825
  var faqSchema = z.object({
@@ -1605,23 +1829,39 @@ var faqSchema = z.object({
1605
1829
  sortOrder: z.number().int().default(0)
1606
1830
  });
1607
1831
  var faqsRoute = new Hono();
1608
- faqsRoute.get("/", async (c) => {
1609
- const db = createDb(c.env.DB);
1610
- const cache = new CacheService(c.env.KV);
1611
- const data = await cache.getOrSet(
1612
- FAQS_KV_KEY,
1613
- () => db.query.faqs.findMany({
1614
- orderBy: (t, { asc }) => [asc(t.sortOrder), asc(t.createdAt)]
1615
- }),
1616
- FAQS_TTL
1617
- );
1618
- return c.json({ data });
1619
- });
1832
+ faqsRoute.get(
1833
+ "/",
1834
+ describeRoute({
1835
+ tags: ["FAQs"],
1836
+ summary: "List all active FAQs",
1837
+ responses: { 200: { description: "List of FAQs" } }
1838
+ }),
1839
+ async (c) => {
1840
+ const db = createDb(c.env.DB);
1841
+ const cache = new CacheService(c.env.KV);
1842
+ const data = await cache.getOrSet(
1843
+ FAQS_KV_KEY,
1844
+ () => db.query.faqs.findMany({
1845
+ orderBy: (t, { asc: asc2 }) => [asc2(t.sortOrder), asc2(t.createdAt)]
1846
+ }),
1847
+ FAQS_TTL
1848
+ );
1849
+ return c.json({ data });
1850
+ }
1851
+ );
1620
1852
  faqsRoute.post(
1621
1853
  "/",
1854
+ describeRoute({
1855
+ tags: ["FAQs"],
1856
+ summary: "Create a new FAQ (admin)",
1857
+ responses: {
1858
+ 201: { description: "FAQ created" },
1859
+ 422: { description: "Validation error" }
1860
+ }
1861
+ }),
1622
1862
  authMiddleware,
1623
1863
  adminMiddleware,
1624
- zValidator("json", faqSchema),
1864
+ validator("json", faqSchema),
1625
1865
  async (c) => {
1626
1866
  const body = c.req.valid("json");
1627
1867
  const db = createDb(c.env.DB);
@@ -1631,65 +1871,105 @@ faqsRoute.post(
1631
1871
  return c.json({ data: created }, 201);
1632
1872
  }
1633
1873
  );
1634
- faqsRoute.patch("/:id", authMiddleware, adminMiddleware, async (c) => {
1635
- const { id } = c.req.param();
1636
- const body = await c.req.json();
1637
- const db = createDb(c.env.DB);
1638
- const cache = new CacheService(c.env.KV);
1639
- const now = Math.floor(Date.now() / 1e3);
1640
- const [updated] = await db.update(faqs).set({ ...body, updatedAt: now }).where(eq(faqs.id, id)).returning();
1641
- if (!updated) throw notFound("FAQ");
1642
- await cache.del(FAQS_KV_KEY);
1643
- return c.json({ data: updated });
1644
- });
1645
- faqsRoute.delete("/:id", authMiddleware, adminMiddleware, async (c) => {
1646
- const { id } = c.req.param();
1647
- const db = createDb(c.env.DB);
1648
- const cache = new CacheService(c.env.KV);
1649
- const [deleted] = await db.delete(faqs).where(eq(faqs.id, id)).returning();
1650
- if (!deleted) throw notFound("FAQ");
1651
- await cache.del(FAQS_KV_KEY);
1652
- return c.json({ data: { deleted: true } });
1653
- });
1654
- var gformsWebhookRoute = new Hono();
1655
- gformsWebhookRoute.post("/", internalMiddleware, async (c) => {
1656
- const rawBody = await c.req.text();
1657
- const signature = c.req.header("X-Goog-Signature");
1658
- if (signature) {
1659
- const isValid = await verifyGoogSignature(
1660
- rawBody,
1661
- signature,
1662
- c.env.GFORMS_WEBHOOK_SECRET
1663
- );
1664
- if (!isValid) {
1665
- return c.json({ error: "Invalid signature" }, 403);
1874
+ faqsRoute.patch(
1875
+ "/:id",
1876
+ describeRoute({
1877
+ tags: ["FAQs"],
1878
+ summary: "Update an FAQ (admin)",
1879
+ responses: {
1880
+ 200: { description: "FAQ updated" },
1881
+ 404: { description: "FAQ not found" }
1666
1882
  }
1883
+ }),
1884
+ authMiddleware,
1885
+ adminMiddleware,
1886
+ async (c) => {
1887
+ const { id } = c.req.param();
1888
+ const body = await c.req.json();
1889
+ const db = createDb(c.env.DB);
1890
+ const cache = new CacheService(c.env.KV);
1891
+ const now = Math.floor(Date.now() / 1e3);
1892
+ const [updated] = await db.update(faqs).set({ ...body, updatedAt: now }).where(eq(faqs.id, id)).returning();
1893
+ if (!updated) throw notFound("FAQ");
1894
+ await cache.del(FAQS_KV_KEY);
1895
+ return c.json({ data: updated });
1667
1896
  }
1668
- let payload;
1669
- try {
1670
- payload = JSON.parse(rawBody);
1671
- } catch {
1672
- return c.json({ error: "Invalid payload" }, 400);
1673
- }
1674
- const { formId } = payload;
1675
- if (!formId) return c.json({ error: "Missing formId" }, 400);
1676
- const db = createDb(c.env.DB);
1677
- const cache = new CacheService(c.env.KV);
1678
- const event = await db.query.events.findFirst({
1679
- where: eq(events.gformsId, formId),
1680
- columns: { slug: true, maxSlots: true, registeredSlots: true }
1681
- });
1682
- if (!event) {
1683
- console.warn(`[gforms-webhook] Unknown formId: ${formId}`);
1897
+ );
1898
+ faqsRoute.delete(
1899
+ "/:id",
1900
+ describeRoute({
1901
+ tags: ["FAQs"],
1902
+ summary: "Delete an FAQ (admin)",
1903
+ responses: {
1904
+ 200: { description: "FAQ deleted" },
1905
+ 404: { description: "FAQ not found" }
1906
+ }
1907
+ }),
1908
+ authMiddleware,
1909
+ adminMiddleware,
1910
+ async (c) => {
1911
+ const { id } = c.req.param();
1912
+ const db = createDb(c.env.DB);
1913
+ const cache = new CacheService(c.env.KV);
1914
+ const [deleted] = await db.delete(faqs).where(eq(faqs.id, id)).returning();
1915
+ if (!deleted) throw notFound("FAQ");
1916
+ await cache.del(FAQS_KV_KEY);
1917
+ return c.json({ data: { deleted: true } });
1918
+ }
1919
+ );
1920
+ var gformsWebhookRoute = new Hono();
1921
+ gformsWebhookRoute.post(
1922
+ "/",
1923
+ describeRoute({
1924
+ tags: ["Internal"],
1925
+ summary: "Google Forms webhook receiver",
1926
+ description: "Receives Google Forms Watch push notifications and increments slot counters.",
1927
+ responses: {
1928
+ 200: { description: "Notification processed" },
1929
+ 400: { description: "Invalid payload" },
1930
+ 403: { description: "Invalid HMAC signature" }
1931
+ }
1932
+ }),
1933
+ internalMiddleware,
1934
+ async (c) => {
1935
+ const rawBody = await c.req.text();
1936
+ const signature = c.req.header("X-Goog-Signature");
1937
+ if (signature) {
1938
+ const isValid = await verifyGoogSignature(
1939
+ rawBody,
1940
+ signature,
1941
+ c.env.GFORMS_WEBHOOK_SECRET
1942
+ );
1943
+ if (!isValid) {
1944
+ return c.json({ error: "Invalid signature" }, 403);
1945
+ }
1946
+ }
1947
+ let payload;
1948
+ try {
1949
+ payload = JSON.parse(rawBody);
1950
+ } catch {
1951
+ return c.json({ error: "Invalid payload" }, 400);
1952
+ }
1953
+ const { formId } = payload;
1954
+ if (!formId) return c.json({ error: "Missing formId" }, 400);
1955
+ const db = createDb(c.env.DB);
1956
+ const cache = new CacheService(c.env.KV);
1957
+ const event = await db.query.events.findFirst({
1958
+ where: eq(events.gformsId, formId),
1959
+ columns: { slug: true, maxSlots: true, registeredSlots: true }
1960
+ });
1961
+ if (!event) {
1962
+ console.warn(`[gforms-webhook] Unknown formId: ${formId}`);
1963
+ return c.json({ ok: true });
1964
+ }
1965
+ const slotsService = new SlotsService(db, cache);
1966
+ const updated = await slotsService.increment(event.slug);
1967
+ console.log(
1968
+ `[gforms-webhook] Incremented "${event.slug}": ${updated?.registered}/${updated?.total}`
1969
+ );
1684
1970
  return c.json({ ok: true });
1685
1971
  }
1686
- const slotsService = new SlotsService(db, cache);
1687
- const updated = await slotsService.increment(event.slug);
1688
- console.log(
1689
- `[gforms-webhook] Incremented "${event.slug}": ${updated?.registered}/${updated?.total}`
1690
- );
1691
- return c.json({ ok: true });
1692
- });
1972
+ );
1693
1973
  async function verifyGoogSignature(body, signature, secret) {
1694
1974
  try {
1695
1975
  const key = await crypto.subtle.importKey(
@@ -1758,10 +2038,20 @@ async function reconcileSlots(env) {
1758
2038
 
1759
2039
  // src/routes/internal/reconcile-slots.ts
1760
2040
  var reconcileSlotsRoute = new Hono();
1761
- reconcileSlotsRoute.post("/", internalMiddleware, async (c) => {
1762
- await reconcileSlots(c.env);
1763
- return c.json({ ok: true });
1764
- });
2041
+ reconcileSlotsRoute.post(
2042
+ "/",
2043
+ describeRoute({
2044
+ tags: ["Internal"],
2045
+ summary: "Reconcile event slot counts",
2046
+ description: "Triggers slot count reconciliation for all events with Google Forms.",
2047
+ responses: { 200: { description: "Reconciliation complete" } }
2048
+ }),
2049
+ internalMiddleware,
2050
+ async (c) => {
2051
+ await reconcileSlots(c.env);
2052
+ return c.json({ ok: true });
2053
+ }
2054
+ );
1765
2055
  async function batchRelease(env) {
1766
2056
  const db = createDb(env.DB);
1767
2057
  const cache = new CacheService(env.KV);
@@ -1789,10 +2079,20 @@ async function batchRelease(env) {
1789
2079
 
1790
2080
  // src/routes/internal/batch-release.ts
1791
2081
  var batchReleaseRoute = new Hono();
1792
- batchReleaseRoute.post("/", internalMiddleware, async (c) => {
1793
- await batchRelease(c.env);
1794
- return c.json({ ok: true });
1795
- });
2082
+ batchReleaseRoute.post(
2083
+ "/",
2084
+ describeRoute({
2085
+ tags: ["Internal"],
2086
+ summary: "Batch release queued events",
2087
+ description: "Triggers batch release of queued events whose releaseAt time has passed.",
2088
+ responses: { 200: { description: "Batch release complete" } }
2089
+ }),
2090
+ internalMiddleware,
2091
+ async (c) => {
2092
+ await batchRelease(c.env);
2093
+ return c.json({ ok: true });
2094
+ }
2095
+ );
1796
2096
  function parseStartTimestamp(dateTime, startTime) {
1797
2097
  if (!dateTime) return null;
1798
2098
  const combined = startTime ? `${dateTime} ${startTime}` : dateTime;
@@ -1864,10 +2164,20 @@ async function reminderEmails(env) {
1864
2164
 
1865
2165
  // src/routes/internal/reminder-emails.ts
1866
2166
  var reminderEmailsRoute = new Hono();
1867
- reminderEmailsRoute.post("/", internalMiddleware, async (c) => {
1868
- await reminderEmails(c.env);
1869
- return c.json({ ok: true });
1870
- });
2167
+ reminderEmailsRoute.post(
2168
+ "/",
2169
+ describeRoute({
2170
+ tags: ["Internal"],
2171
+ summary: "Send reminder emails",
2172
+ description: "Triggers reminder email processing for upcoming events.",
2173
+ responses: { 200: { description: "Reminder emails processed" } }
2174
+ }),
2175
+ internalMiddleware,
2176
+ async (c) => {
2177
+ await reminderEmails(c.env);
2178
+ return c.json({ ok: true });
2179
+ }
2180
+ );
1871
2181
  var RENEWAL_WINDOW = 86400;
1872
2182
  async function renewWatches(env) {
1873
2183
  const db = createDb(env.DB);
@@ -1899,10 +2209,20 @@ async function renewWatches(env) {
1899
2209
 
1900
2210
  // src/routes/internal/renew-watches.ts
1901
2211
  var renewWatchesRoute = new Hono();
1902
- renewWatchesRoute.post("/", internalMiddleware, async (c) => {
1903
- await renewWatches(c.env);
1904
- return c.json({ ok: true });
1905
- });
2212
+ renewWatchesRoute.post(
2213
+ "/",
2214
+ describeRoute({
2215
+ tags: ["Internal"],
2216
+ summary: "Renew Google Forms watches",
2217
+ description: "Triggers renewal of expiring Google Forms Watch subscriptions.",
2218
+ responses: { 200: { description: "Watch renewal complete" } }
2219
+ }),
2220
+ internalMiddleware,
2221
+ async (c) => {
2222
+ await renewWatches(c.env);
2223
+ return c.json({ ok: true });
2224
+ }
2225
+ );
1906
2226
  var ALLOWED_MIME_TYPES = /* @__PURE__ */ new Set([
1907
2227
  "image/jpeg",
1908
2228
  "image/png",
@@ -1912,30 +2232,50 @@ var ALLOWED_MIME_TYPES = /* @__PURE__ */ new Set([
1912
2232
  ]);
1913
2233
  var MAX_FILE_SIZE = 10 * 1024 * 1024;
1914
2234
  var uploadsRoute = new Hono();
1915
- uploadsRoute.get("/images/*", async (c) => {
1916
- const bucket = c.env.FILES;
1917
- if (!bucket) {
1918
- throw serviceUnavailable("File storage (R2) is not configured.");
1919
- }
1920
- const path = c.req.path.split("/uploads/images/")[1];
1921
- if (!path) throw notFound("Image");
1922
- const object = await bucket.get(path);
1923
- if (!object) throw notFound("Image");
1924
- const headers = {
1925
- etag: object.httpEtag,
1926
- // Cache at the edge/browser for 1 month
1927
- "Cache-Control": "public, max-age=2592000, immutable"
1928
- };
1929
- if (object.httpMetadata?.contentType) {
1930
- headers["Content-Type"] = object.httpMetadata.contentType;
1931
- }
1932
- if (object.httpMetadata?.cacheControl) {
1933
- headers["Cache-Control"] = object.httpMetadata.cacheControl;
2235
+ uploadsRoute.get(
2236
+ "/images/*",
2237
+ describeRoute({
2238
+ tags: ["Uploads"],
2239
+ summary: "Serve an image from R2 storage",
2240
+ responses: {
2241
+ 200: { description: "Image file" },
2242
+ 404: { description: "Image not found" }
2243
+ }
2244
+ }),
2245
+ async (c) => {
2246
+ const bucket = c.env.FILES;
2247
+ if (!bucket) {
2248
+ throw serviceUnavailable("File storage (R2) is not configured.");
2249
+ }
2250
+ const path = c.req.path.split("/uploads/images/")[1];
2251
+ if (!path) throw notFound("Image");
2252
+ const object = await bucket.get(path);
2253
+ if (!object) throw notFound("Image");
2254
+ const headers = {
2255
+ etag: object.httpEtag,
2256
+ // Cache at the edge/browser for 1 month
2257
+ "Cache-Control": "public, max-age=2592000, immutable"
2258
+ };
2259
+ if (object.httpMetadata?.contentType) {
2260
+ headers["Content-Type"] = object.httpMetadata.contentType;
2261
+ }
2262
+ if (object.httpMetadata?.cacheControl) {
2263
+ headers["Cache-Control"] = object.httpMetadata.cacheControl;
2264
+ }
2265
+ return c.body(object.body, 200, headers);
1934
2266
  }
1935
- return c.body(object.body, 200, headers);
1936
- });
2267
+ );
1937
2268
  uploadsRoute.post(
1938
2269
  "/images",
2270
+ describeRoute({
2271
+ tags: ["Uploads"],
2272
+ summary: "Upload an image to R2 storage (admin)",
2273
+ responses: {
2274
+ 201: { description: "Image uploaded successfully" },
2275
+ 400: { description: "Invalid file or MIME type" },
2276
+ 503: { description: "R2 storage not configured" }
2277
+ }
2278
+ }),
1939
2279
  authMiddleware,
1940
2280
  adminMiddleware,
1941
2281
  async (c) => {
@@ -2002,26 +2342,57 @@ function extensionFromMime(mime) {
2002
2342
  };
2003
2343
  return map[mime] ?? "bin";
2004
2344
  }
2345
+ function generatePath(name) {
2346
+ return name.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
2347
+ }
2005
2348
  var createThemeSchema = z.object({
2006
2349
  name: z.string().min(1),
2007
- path: z.string().min(1)
2350
+ imageUrl: z.string().url().nullable().optional(),
2351
+ descriptionEn: z.string().nullable().optional(),
2352
+ descriptionFil: z.string().nullable().optional(),
2353
+ sortOrder: z.number().int().default(0)
2008
2354
  });
2009
- var themesRoute = new Hono();
2010
- themesRoute.get("/", async (c) => {
2011
- const db = createDb(c.env.DB);
2012
- const data = await db.select().from(themes);
2013
- return c.json({ data });
2355
+ z.object({
2356
+ name: z.string().min(1).optional(),
2357
+ imageUrl: z.string().url().nullable().optional(),
2358
+ descriptionEn: z.string().nullable().optional(),
2359
+ descriptionFil: z.string().nullable().optional(),
2360
+ sortOrder: z.number().int().optional()
2014
2361
  });
2362
+ var themesRoute = new Hono();
2363
+ themesRoute.get(
2364
+ "/",
2365
+ describeRoute({
2366
+ tags: ["Themes"],
2367
+ summary: "List all themes",
2368
+ responses: { 200: { description: "List of themes" } }
2369
+ }),
2370
+ async (c) => {
2371
+ const db = createDb(c.env.DB);
2372
+ const data = await db.select().from(themes).orderBy(asc(themes.sortOrder), asc(themes.createdAt));
2373
+ return c.json({ data });
2374
+ }
2375
+ );
2015
2376
  themesRoute.post(
2016
2377
  "/",
2378
+ describeRoute({
2379
+ tags: ["Themes"],
2380
+ summary: "Create a new theme (admin)",
2381
+ responses: {
2382
+ 201: { description: "Theme created" },
2383
+ 409: { description: "Theme already exists" },
2384
+ 422: { description: "Validation error" }
2385
+ }
2386
+ }),
2017
2387
  authMiddleware,
2018
2388
  adminMiddleware,
2019
- zValidator("json", createThemeSchema),
2389
+ validator("json", createThemeSchema),
2020
2390
  async (c) => {
2021
2391
  const body = c.req.valid("json");
2022
2392
  const db = createDb(c.env.DB);
2393
+ const path = generatePath(body.name);
2023
2394
  try {
2024
- const [created] = await db.insert(themes).values(body).returning();
2395
+ const [created] = await db.insert(themes).values({ ...body, path }).returning();
2025
2396
  return c.json({ data: created }, 201);
2026
2397
  } catch (err) {
2027
2398
  if (err.message && err.message.includes("UNIQUE constraint failed")) {
@@ -2033,14 +2404,27 @@ themesRoute.post(
2033
2404
  );
2034
2405
  themesRoute.patch(
2035
2406
  "/:id",
2407
+ describeRoute({
2408
+ tags: ["Themes"],
2409
+ summary: "Update a theme (admin)",
2410
+ responses: {
2411
+ 200: { description: "Theme updated" },
2412
+ 404: { description: "Theme not found" },
2413
+ 409: { description: "Theme already exists" }
2414
+ }
2415
+ }),
2036
2416
  authMiddleware,
2037
2417
  adminMiddleware,
2038
2418
  async (c) => {
2039
2419
  const { id } = c.req.param();
2040
2420
  const body = await c.req.json();
2041
2421
  const db = createDb(c.env.DB);
2422
+ const update = { ...body };
2423
+ if (body.name) {
2424
+ update.path = generatePath(body.name);
2425
+ }
2042
2426
  try {
2043
- const [updated] = await db.update(themes).set(body).where(eq(themes.id, id)).returning();
2427
+ const [updated] = await db.update(themes).set(update).where(eq(themes.id, id)).returning();
2044
2428
  if (!updated) throw notFound("Theme");
2045
2429
  return c.json({ data: updated });
2046
2430
  } catch (err) {
@@ -2051,13 +2435,26 @@ themesRoute.patch(
2051
2435
  }
2052
2436
  }
2053
2437
  );
2054
- themesRoute.delete("/:id", authMiddleware, adminMiddleware, async (c) => {
2055
- const { id } = c.req.param();
2056
- const db = createDb(c.env.DB);
2057
- const [deleted] = await db.delete(themes).where(eq(themes.id, id)).returning();
2058
- if (!deleted) throw notFound("Theme");
2059
- return c.body(null, 204);
2060
- });
2438
+ themesRoute.delete(
2439
+ "/:id",
2440
+ describeRoute({
2441
+ tags: ["Themes"],
2442
+ summary: "Delete a theme (admin)",
2443
+ responses: {
2444
+ 204: { description: "Theme deleted" },
2445
+ 404: { description: "Theme not found" }
2446
+ }
2447
+ }),
2448
+ authMiddleware,
2449
+ adminMiddleware,
2450
+ async (c) => {
2451
+ const { id } = c.req.param();
2452
+ const db = createDb(c.env.DB);
2453
+ const [deleted] = await db.delete(themes).where(eq(themes.id, id)).returning();
2454
+ if (!deleted) throw notFound("Theme");
2455
+ return c.body(null, 204);
2456
+ }
2457
+ );
2061
2458
  var createOrganizationSchema = z.object({
2062
2459
  name: z.string().min(1),
2063
2460
  acronym: z.string().min(1),
@@ -2065,16 +2462,33 @@ var createOrganizationSchema = z.object({
2065
2462
  link: z.string().url().nullable().optional()
2066
2463
  });
2067
2464
  var organizationsRoute = new Hono();
2068
- organizationsRoute.get("/", async (c) => {
2069
- const db = createDb(c.env.DB);
2070
- const data = await db.select().from(organizations);
2071
- return c.json({ data });
2072
- });
2465
+ organizationsRoute.get(
2466
+ "/",
2467
+ describeRoute({
2468
+ tags: ["Organizations"],
2469
+ summary: "List all organizations",
2470
+ responses: { 200: { description: "List of organizations" } }
2471
+ }),
2472
+ async (c) => {
2473
+ const db = createDb(c.env.DB);
2474
+ const data = await db.select().from(organizations);
2475
+ return c.json({ data });
2476
+ }
2477
+ );
2073
2478
  organizationsRoute.post(
2074
2479
  "/",
2480
+ describeRoute({
2481
+ tags: ["Organizations"],
2482
+ summary: "Create a new organization (admin)",
2483
+ responses: {
2484
+ 201: { description: "Organization created" },
2485
+ 409: { description: "Organization already exists" },
2486
+ 422: { description: "Validation error" }
2487
+ }
2488
+ }),
2075
2489
  authMiddleware,
2076
2490
  adminMiddleware,
2077
- zValidator("json", createOrganizationSchema),
2491
+ validator("json", createOrganizationSchema),
2078
2492
  async (c) => {
2079
2493
  const body = c.req.valid("json");
2080
2494
  const db = createDb(c.env.DB);
@@ -2091,6 +2505,15 @@ organizationsRoute.post(
2091
2505
  );
2092
2506
  organizationsRoute.patch(
2093
2507
  "/:id",
2508
+ describeRoute({
2509
+ tags: ["Organizations"],
2510
+ summary: "Update an organization (admin)",
2511
+ responses: {
2512
+ 200: { description: "Organization updated" },
2513
+ 404: { description: "Organization not found" },
2514
+ 409: { description: "Organization already exists" }
2515
+ }
2516
+ }),
2094
2517
  authMiddleware,
2095
2518
  adminMiddleware,
2096
2519
  async (c) => {
@@ -2109,13 +2532,26 @@ organizationsRoute.patch(
2109
2532
  }
2110
2533
  }
2111
2534
  );
2112
- organizationsRoute.delete("/:id", authMiddleware, adminMiddleware, async (c) => {
2113
- const { id } = c.req.param();
2114
- const db = createDb(c.env.DB);
2115
- const [deleted] = await db.delete(organizations).where(eq(organizations.id, id)).returning();
2116
- if (!deleted) throw notFound("Organization");
2117
- return c.body(null, 204);
2118
- });
2535
+ organizationsRoute.delete(
2536
+ "/:id",
2537
+ describeRoute({
2538
+ tags: ["Organizations"],
2539
+ summary: "Delete an organization (admin)",
2540
+ responses: {
2541
+ 204: { description: "Organization deleted" },
2542
+ 404: { description: "Organization not found" }
2543
+ }
2544
+ }),
2545
+ authMiddleware,
2546
+ adminMiddleware,
2547
+ async (c) => {
2548
+ const { id } = c.req.param();
2549
+ const db = createDb(c.env.DB);
2550
+ const [deleted] = await db.delete(organizations).where(eq(organizations.id, id)).returning();
2551
+ if (!deleted) throw notFound("Organization");
2552
+ return c.body(null, 204);
2553
+ }
2554
+ );
2119
2555
 
2120
2556
  // src/app.ts
2121
2557
  function createApp(options = {}) {
@@ -2173,6 +2609,27 @@ function createApp(options = {}) {
2173
2609
  app2.route("/internal/batch-release", batchReleaseRoute);
2174
2610
  app2.route("/internal/reminder-emails", reminderEmailsRoute);
2175
2611
  app2.route("/internal/renew-watches", renewWatchesRoute);
2612
+ app2.get(
2613
+ "/api/openapi.json",
2614
+ authMiddleware,
2615
+ adminMiddleware,
2616
+ openAPIRouteHandler(app2, {
2617
+ documentation: {
2618
+ info: {
2619
+ title: "Leapify API",
2620
+ version: "0.260602.1",
2621
+ description: "DLSU CSO LEAP backend API"
2622
+ },
2623
+ openapi: "3.1.0"
2624
+ }
2625
+ })
2626
+ );
2627
+ app2.get(
2628
+ "/api/docs",
2629
+ authMiddleware,
2630
+ adminMiddleware,
2631
+ swaggerUI({ url: "/api/openapi.json" })
2632
+ );
2176
2633
  app2.onError(errorHandler);
2177
2634
  app2.notFound(
2178
2635
  (c) => c.json({ error: { code: "NOT_FOUND", message: "Route not found" } }, 404)
@@ -2278,7 +2735,6 @@ var SesError = class extends Error {
2278
2735
  this.status = status;
2279
2736
  this.name = "SesError";
2280
2737
  }
2281
- status;
2282
2738
  /**
2283
2739
  * True for errors that are permanent (not worth retrying via SES again).
2284
2740
  * 400 BadRequest, 403 Forbidden, 404 NotFound → non-retryable.