@access-dlsu/leapify 0.260602.1 → 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.
package/dist/worker.js CHANGED
@@ -1,4 +1,6 @@
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';
@@ -7,7 +9,6 @@ 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;
@@ -409,6 +410,8 @@ var EXEMPT_PATHS = [
409
410
  "/api/config",
410
411
  "/api/themes",
411
412
  "/api/organizations",
413
+ "/api/docs",
414
+ "/api/openapi.json",
412
415
  TURNSTILE_VERIFY_PATH
413
416
  ];
414
417
  function base64urlEncode(bytes) {
@@ -738,8 +741,6 @@ var internalMiddleware = createMiddleware(async (c, next) => {
738
741
  }
739
742
  return next();
740
743
  });
741
-
742
- // src/routes/health.ts
743
744
  var healthRoute = new Hono();
744
745
  async function probeResend(apiKey) {
745
746
  const start = Date.now();
@@ -841,69 +842,87 @@ async function probeGForms(serviceAccountJson) {
841
842
  };
842
843
  }
843
844
  }
844
- healthRoute.get("/", async (c) => {
845
- const env = c.env;
846
- const hasSes = Boolean(env.SES_REGION) && Boolean(env.SES_ACCESS_KEY_ID) && Boolean(env.SES_SECRET_ACCESS_KEY);
847
- const hasResend = Boolean(env.RESEND_API_KEY);
848
- let hasGForms = false;
849
- if (env.GFORMS_SERVICE_ACCOUNT_JSON) {
850
- try {
851
- const parsed = JSON.parse(env.GFORMS_SERVICE_ACCOUNT_JSON);
852
- hasGForms = Boolean(parsed.client_email && parsed.private_key);
853
- } 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
+ }
854
863
  }
855
- }
856
- const probes = [];
857
- if (hasSes) {
858
- probes.push(
859
- probeSes(env.SES_REGION, env.SES_ACCESS_KEY_ID, env.SES_SECRET_ACCESS_KEY).then(
860
- (h) => ["ses", h]
861
- )
862
- );
863
- }
864
- if (hasResend) {
865
- probes.push(
866
- probeResend(env.RESEND_API_KEY).then((h) => ["resend", h])
867
- );
868
- }
869
- if (hasGForms) {
870
- probes.push(
871
- probeGForms(env.GFORMS_SERVICE_ACCOUNT_JSON).then(
872
- (h) => ["gforms", h]
873
- )
874
- );
875
- }
876
- const results = await Promise.all(probes);
877
- const services = {};
878
- for (const [name, health] of results) {
879
- services[name] = health;
880
- }
881
- const allOk = results.length === 0 || results.every(([, h]) => h.ok);
882
- return c.json({
883
- data: {
884
- status: allOk ? "OK" : "DEGRADED",
885
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
886
- 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
+ );
887
871
  }
888
- });
889
- });
890
- healthRoute.post("/queue-burst", authMiddleware, adminMiddleware, async (c) => {
891
- if (!c.env.EMAIL_QUEUE) {
892
- return c.json({ error: "Queue binding missing" }, 400);
893
- }
894
- const batch = Array.from({ length: 100 }, (_, i) => ({
895
- body: {
896
- type: "audit_log",
897
- payload: {
898
- action: "queue_load_test",
899
- userId: "system",
900
- 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
901
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);
902
911
  }
903
- }));
904
- await c.env.EMAIL_QUEUE.sendBatch(batch);
905
- return c.json({ status: "queued", count: 100 });
906
- });
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
+ );
907
926
 
908
927
  // src/services/cache.ts
909
928
  var CacheService = class {
@@ -1276,143 +1295,221 @@ var classesRoute = new Hono();
1276
1295
  function generateSlug(title) {
1277
1296
  return title.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
1278
1297
  }
1279
- classesRoute.get("/admin", authMiddleware, adminMiddleware, async (c) => {
1280
- const db = createDb(c.env.DB);
1281
- const data = await db.query.events.findMany({
1282
- with: { theme: true, organization: true },
1283
- orderBy: (e, { desc }) => [desc(e.createdAt)]
1284
- });
1285
- return c.json({ data });
1286
- });
1287
- classesRoute.post("/admin/publish", authMiddleware, adminMiddleware, async (c) => {
1288
- const body = await c.req.json();
1289
- const db = createDb(c.env.DB);
1290
- const cache = new CacheService(c.env.KV);
1291
- if (!body.ids?.length) {
1292
- return c.json({ error: "ids are required" }, 400);
1293
- }
1294
- if (body.releaseAt) {
1295
- await db.update(events).set({ releaseAt: body.releaseAt, status: "queued" }).where(
1296
- sql`${events.id} IN (${sql.join(
1297
- body.ids.map((id) => sql`${id}`),
1298
- sql`, `
1299
- )})`
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
1300
1409
  );
1301
- } else {
1302
- await db.update(events).set({ status: "published", publishedAt: sql`(unixepoch())` }).where(
1303
- sql`${events.id} IN (${sql.join(
1304
- body.ids.map((id) => sql`${id}`),
1305
- sql`, `
1306
- )})`
1410
+ c.header("ETag", etag);
1411
+ c.header(
1412
+ "Cache-Control",
1413
+ "public, max-age=604800, stale-while-revalidate=86400"
1307
1414
  );
1415
+ return c.json({ data });
1308
1416
  }
1309
- await Promise.all([
1310
- cache.del(EVENTS_LIST_KV_KEY),
1311
- cache.del(EVENTS_ETAG_KV_KEY)
1312
- ]);
1313
- return c.json({ data: { updated: body.ids.length } });
1314
- });
1315
- classesRoute.get("/", eventsListRateLimit, async (c) => {
1316
- const db = createDb(c.env.DB);
1317
- const cache = new CacheService(c.env.KV);
1318
- const [latest] = await db.select({ max: events.publishedAt }).from(events).where(eq(events.status, "published")).limit(1);
1319
- const etag = await cache.getOrSet(
1320
- EVENTS_ETAG_KV_KEY,
1321
- () => cache.generateETag(String(latest?.max ?? "0")),
1322
- 300
1323
- );
1324
- const ifNoneMatch = c.req.header("If-None-Match");
1325
- if (ifNoneMatch === etag) {
1326
- return c.body(null, 304);
1327
- }
1328
- const data = await cache.getOrSet(
1329
- EVENTS_LIST_KV_KEY,
1330
- () => db.query.events.findMany({
1331
- 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")),
1332
1433
  with: {
1333
- theme: true,
1334
- organization: true
1335
- },
1336
- columns: {
1337
- id: true,
1338
- slug: true,
1339
- themeId: true,
1340
- organizationId: true,
1341
- title: true,
1342
- venue: true,
1343
- dateTime: true,
1344
- price: true,
1345
- backgroundImageUrl: true,
1346
- classCode: true,
1347
- startTime: true,
1348
- endTime: true,
1349
- registrationClosesAt: true,
1350
- isSpotlight: true,
1351
- maxSlots: true,
1352
- registeredSlots: true,
1353
- gformsUrl: true,
1354
- gformsEditorUrl: true,
1355
- publishedAt: true
1434
+ theme: true
1356
1435
  }
1357
- }),
1358
- EVENTS_LIST_TTL
1359
- );
1360
- c.header("ETag", etag);
1361
- c.header(
1362
- "Cache-Control",
1363
- "public, max-age=604800, stale-while-revalidate=86400"
1364
- );
1365
- return c.json({ data });
1366
- });
1367
- classesRoute.get("/:slug", async (c) => {
1368
- const { slug } = c.req.param();
1369
- const db = createDb(c.env.DB);
1370
- const event = await db.query.events.findFirst({
1371
- where: and(eq(events.slug, slug), eq(events.status, "published")),
1372
- with: {
1373
- 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" }
1374
1449
  }
1375
- });
1376
- if (!event) throw notFound("Event");
1377
- return c.json({ data: event });
1378
- });
1379
- classesRoute.get("/:slug/slots", eventsSlotsRateLimit, async (c) => {
1380
- const { slug } = c.req.param();
1381
- const db = createDb(c.env.DB);
1382
- const cache = new CacheService(c.env.KV);
1383
- const slotsService = new SlotsService(db, cache);
1384
- const info = await slotsService.getSlots(slug);
1385
- if (!info) throw notFound("Event");
1386
- c.header("Cache-Control", "public, max-age=5, stale-while-revalidate=5");
1387
- return c.json({ data: info });
1388
- });
1389
- classesRoute.post("/:slug/reconcile", authMiddleware, adminMiddleware, async (c) => {
1390
- const { slug } = c.req.param();
1391
- const db = createDb(c.env.DB);
1392
- const cache = new CacheService(c.env.KV);
1393
- const gforms = new GFormsService(c.env.GFORMS_SERVICE_ACCOUNT_JSON);
1394
- const slots = new SlotsService(db, cache);
1395
- const event = await db.query.events.findFirst({
1396
- where: eq(events.slug, slug),
1397
- columns: { gformsId: true }
1398
- });
1399
- if (!event) throw notFound("Event");
1400
- if (!event.gformsId) return c.json({ error: "No gformsId set for this event" }, 400);
1401
- try {
1402
- const googleCount = await gforms.getExactResponseCount(event.gformsId);
1403
- await slots.correctCount(slug, googleCount);
1404
- return c.json({ data: { registeredSlots: googleCount } });
1405
- } catch (err) {
1406
- const message = err?.message ?? "Failed to fetch from Google Forms API";
1407
- return c.json({ error: { code: "GFORMS_API_ERROR", message } }, 502);
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 });
1408
1461
  }
1409
- });
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
+ );
1410
1499
  classesRoute.post(
1411
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
+ }),
1412
1509
  authMiddleware,
1413
1510
  adminMiddleware,
1414
1511
  adminEventsRateLimit,
1415
- zValidator("json", createEventSchema),
1512
+ validator("json", createEventSchema),
1416
1513
  async (c) => {
1417
1514
  const body = c.req.valid("json");
1418
1515
  const db = createDb(c.env.DB);
@@ -1445,158 +1542,284 @@ classesRoute.post(
1445
1542
  return c.json({ data: created }, 201);
1446
1543
  }
1447
1544
  );
1448
- classesRoute.patch("/:slug", authMiddleware, adminMiddleware, async (c) => {
1449
- const { slug } = c.req.param();
1450
- const body = await c.req.json();
1451
- const db = createDb(c.env.DB);
1452
- const cache = new CacheService(c.env.KV);
1453
- let newSlug;
1454
- if (body.title) {
1455
- newSlug = generateSlug(body.title);
1456
- }
1457
- const [updated] = await db.update(events).set(newSlug ? { ...body, slug: newSlug } : body).where(eq(events.slug, slug)).returning();
1458
- if (!updated) throw notFound("Event");
1459
- await Promise.all([
1460
- cache.del(EVENTS_LIST_KV_KEY),
1461
- cache.del(EVENTS_ETAG_KV_KEY)
1462
- ]);
1463
- return c.json({ data: updated });
1464
- });
1465
- classesRoute.delete("/:slug", authMiddleware, adminMiddleware, async (c) => {
1466
- const { slug } = c.req.param();
1467
- const db = createDb(c.env.DB);
1468
- const cache = new CacheService(c.env.KV);
1469
- const [deleted] = await db.delete(events).where(eq(events.slug, slug)).returning();
1470
- if (!deleted) throw notFound("Event");
1471
- await Promise.all([
1472
- cache.del(EVENTS_LIST_KV_KEY),
1473
- cache.del(EVENTS_ETAG_KV_KEY)
1474
- ]);
1475
- return c.body(null, 204);
1476
- });
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
+ );
1477
1600
  var VALID_ROLES = ["student", "admin", "super_admin"];
1478
1601
  var usersRoute = new Hono();
1479
- usersRoute.get("/", authMiddleware, adminMiddleware, async (c) => {
1480
- const db = createDb(c.env.DB);
1481
- const data = await db.select().from(users);
1482
- return c.json({ data });
1483
- });
1484
- usersRoute.patch("/:id/role", authMiddleware, adminMiddleware, async (c) => {
1485
- const { id } = c.req.param();
1486
- const { role } = await c.req.json();
1487
- if (!role || !VALID_ROLES.includes(role)) {
1488
- throw badRequest("Role must be 'student', 'admin', or 'super_admin'.");
1489
- }
1490
- const db = createDb(c.env.DB);
1491
- const [updated] = await db.update(users).set({ role }).where(eq(users.id, id)).returning();
1492
- if (!updated) throw notFound("User");
1493
- return c.json({ data: updated });
1494
- });
1495
- usersRoute.post("/by-email", authMiddleware, adminMiddleware, async (c) => {
1496
- const { email, role } = await c.req.json();
1497
- if (!email || !role || !VALID_ROLES.includes(role)) {
1498
- throw badRequest("Email and valid role ('student', 'admin', 'super_admin') are required.");
1499
- }
1500
- const db = createDb(c.env.DB);
1501
- const existing = await db.query.users.findFirst({
1502
- where: eq(users.email, email)
1503
- });
1504
- if (existing) {
1505
- 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");
1506
1639
  return c.json({ data: updated });
1507
1640
  }
1508
- const [created] = await db.insert(users).values({ betterAuthId: `pending:${email}`, email, name: email.split("@")[0], role }).returning();
1509
- return c.json({ data: created }, 201);
1510
- });
1511
- usersRoute.get("/me", optionalAuthMiddleware, async (c) => {
1512
- const user = c.get("user");
1513
- if (!user) return c.json({ data: null });
1514
- const db = createDb(c.env.DB);
1515
- const profile = await db.query.users.findFirst({
1516
- where: eq(users.id, user.dbId)
1517
- });
1518
- if (!profile) return c.json({ data: null });
1519
- const auth = await db.query.authUser.findFirst({
1520
- where: eq(authUser.id, profile.betterAuthId),
1521
- columns: { image: true }
1522
- });
1523
- return c.json({ data: { ...profile, image: auth?.image ?? null } });
1524
- });
1525
- usersRoute.get("/me/bookmarks", optionalAuthMiddleware, async (c) => {
1526
- const user = c.get("user");
1527
- if (!user) return c.json({ data: [] });
1528
- const db = createDb(c.env.DB);
1529
- const rows = await db.query.bookmarks.findMany({
1530
- where: eq(bookmarks.userId, user.dbId),
1531
- with: { event: true }
1532
- });
1533
- const data = rows.map((r) => ({ bookmarkedAt: r.createdAt, event: r.event }));
1534
- return c.json({ data });
1535
- });
1536
- usersRoute.post("/me/bookmarks/:eventId", authMiddleware, bookmarksRateLimit, async (c) => {
1537
- const { eventId } = c.req.param();
1538
- const user = c.get("user");
1539
- const db = createDb(c.env.DB);
1540
- const event = await db.query.events.findFirst({
1541
- where: eq(events.id, eventId),
1542
- columns: { id: true }
1543
- });
1544
- if (!event) throw notFound("Event");
1545
- const inserted = await db.insert(bookmarks).values({ userId: user.dbId, eventId }).onConflictDoNothing({ target: [bookmarks.userId, bookmarks.eventId] }).returning();
1546
- if (inserted.length > 0) {
1547
- 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);
1548
1670
  }
1549
- await db.delete(bookmarks).where(and(eq(bookmarks.userId, user.dbId), eq(bookmarks.eventId, eventId)));
1550
- return c.json({ data: { bookmarked: false } }, 200);
1551
- });
1552
- usersRoute.delete("/me/bookmarks/:eventId", authMiddleware, async (c) => {
1553
- const { eventId } = c.req.param();
1554
- const user = c.get("user");
1555
- const db = createDb(c.env.DB);
1556
- await db.delete(bookmarks).where(
1557
- and(eq(bookmarks.userId, user.dbId), eq(bookmarks.eventId, eventId))
1558
- );
1559
- return c.json({ data: { bookmarked: false } });
1560
- });
1561
- var siteConfigRoute = new Hono();
1562
- siteConfigRoute.get("/", async (c) => {
1563
- const db = createDb(c.env.DB);
1564
- const rows = await db.query.siteConfig.findMany();
1565
- const config = Object.fromEntries(
1566
- rows.map((r) => [r.key, JSON.parse(r.value)])
1567
- );
1568
- return c.json({
1569
- data: {
1570
- comingSoonUntil: config.coming_soon_until ?? null,
1571
- siteEndsAt: config.site_ends_at ?? null,
1572
- siteName: config.site_name ?? null,
1573
- registrationGloballyOpen: config.registration_globally_open ?? true,
1574
- maintenanceMode: config.maintenance_mode ?? false,
1575
- allowedOrigins: config.allowed_origins ?? null,
1576
- 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" }
1577
1724
  }
1578
- });
1579
- });
1580
- siteConfigRoute.patch("/:key", authMiddleware, adminMiddleware, async (c) => {
1581
- const key = c.req.param("key");
1582
- const { value } = await c.req.json();
1583
- if (key === "allowed_origins") {
1725
+ }),
1726
+ authMiddleware,
1727
+ bookmarksRateLimit,
1728
+ async (c) => {
1729
+ const { eventId } = c.req.param();
1584
1730
  const user = c.get("user");
1585
- if (!user || user.role !== "super_admin") {
1586
- 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);
1587
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);
1588
1743
  }
1589
- const db = createDb(c.env.DB);
1590
- const now = Math.floor(Date.now() / 1e3);
1591
- await db.insert(siteConfig).values({ key, value: JSON.stringify(value), updatedAt: now }).onConflictDoUpdate({
1592
- target: siteConfig.key,
1593
- set: { value: JSON.stringify(value), updatedAt: now }
1594
- });
1595
- await c.env.KV.put(`config:${key}`, JSON.stringify(value), {
1596
- expirationTtl: 86400
1597
- });
1598
- return c.json({ data: { key, value } });
1599
- });
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
+ );
1600
1823
  var FAQS_KV_KEY = "faqs:active";
1601
1824
  var FAQS_TTL = 600;
1602
1825
  var faqSchema = z.object({
@@ -1606,23 +1829,39 @@ var faqSchema = z.object({
1606
1829
  sortOrder: z.number().int().default(0)
1607
1830
  });
1608
1831
  var faqsRoute = new Hono();
1609
- faqsRoute.get("/", async (c) => {
1610
- const db = createDb(c.env.DB);
1611
- const cache = new CacheService(c.env.KV);
1612
- const data = await cache.getOrSet(
1613
- FAQS_KV_KEY,
1614
- () => db.query.faqs.findMany({
1615
- orderBy: (t, { asc: asc2 }) => [asc2(t.sortOrder), asc2(t.createdAt)]
1616
- }),
1617
- FAQS_TTL
1618
- );
1619
- return c.json({ data });
1620
- });
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
+ );
1621
1852
  faqsRoute.post(
1622
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
+ }),
1623
1862
  authMiddleware,
1624
1863
  adminMiddleware,
1625
- zValidator("json", faqSchema),
1864
+ validator("json", faqSchema),
1626
1865
  async (c) => {
1627
1866
  const body = c.req.valid("json");
1628
1867
  const db = createDb(c.env.DB);
@@ -1632,65 +1871,105 @@ faqsRoute.post(
1632
1871
  return c.json({ data: created }, 201);
1633
1872
  }
1634
1873
  );
1635
- faqsRoute.patch("/:id", authMiddleware, adminMiddleware, async (c) => {
1636
- const { id } = c.req.param();
1637
- const body = await c.req.json();
1638
- const db = createDb(c.env.DB);
1639
- const cache = new CacheService(c.env.KV);
1640
- const now = Math.floor(Date.now() / 1e3);
1641
- const [updated] = await db.update(faqs).set({ ...body, updatedAt: now }).where(eq(faqs.id, id)).returning();
1642
- if (!updated) throw notFound("FAQ");
1643
- await cache.del(FAQS_KV_KEY);
1644
- return c.json({ data: updated });
1645
- });
1646
- faqsRoute.delete("/:id", authMiddleware, adminMiddleware, async (c) => {
1647
- const { id } = c.req.param();
1648
- const db = createDb(c.env.DB);
1649
- const cache = new CacheService(c.env.KV);
1650
- const [deleted] = await db.delete(faqs).where(eq(faqs.id, id)).returning();
1651
- if (!deleted) throw notFound("FAQ");
1652
- await cache.del(FAQS_KV_KEY);
1653
- return c.json({ data: { deleted: true } });
1654
- });
1655
- var gformsWebhookRoute = new Hono();
1656
- gformsWebhookRoute.post("/", internalMiddleware, async (c) => {
1657
- const rawBody = await c.req.text();
1658
- const signature = c.req.header("X-Goog-Signature");
1659
- if (signature) {
1660
- const isValid = await verifyGoogSignature(
1661
- rawBody,
1662
- signature,
1663
- c.env.GFORMS_WEBHOOK_SECRET
1664
- );
1665
- if (!isValid) {
1666
- 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" }
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 });
1896
+ }
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" }
1667
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 } });
1668
1918
  }
1669
- let payload;
1670
- try {
1671
- payload = JSON.parse(rawBody);
1672
- } catch {
1673
- return c.json({ error: "Invalid payload" }, 400);
1674
- }
1675
- const { formId } = payload;
1676
- if (!formId) return c.json({ error: "Missing formId" }, 400);
1677
- const db = createDb(c.env.DB);
1678
- const cache = new CacheService(c.env.KV);
1679
- const event = await db.query.events.findFirst({
1680
- where: eq(events.gformsId, formId),
1681
- columns: { slug: true, maxSlots: true, registeredSlots: true }
1682
- });
1683
- if (!event) {
1684
- console.warn(`[gforms-webhook] Unknown formId: ${formId}`);
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
+ );
1685
1970
  return c.json({ ok: true });
1686
1971
  }
1687
- const slotsService = new SlotsService(db, cache);
1688
- const updated = await slotsService.increment(event.slug);
1689
- console.log(
1690
- `[gforms-webhook] Incremented "${event.slug}": ${updated?.registered}/${updated?.total}`
1691
- );
1692
- return c.json({ ok: true });
1693
- });
1972
+ );
1694
1973
  async function verifyGoogSignature(body, signature, secret) {
1695
1974
  try {
1696
1975
  const key = await crypto.subtle.importKey(
@@ -1759,10 +2038,20 @@ async function reconcileSlots(env) {
1759
2038
 
1760
2039
  // src/routes/internal/reconcile-slots.ts
1761
2040
  var reconcileSlotsRoute = new Hono();
1762
- reconcileSlotsRoute.post("/", internalMiddleware, async (c) => {
1763
- await reconcileSlots(c.env);
1764
- return c.json({ ok: true });
1765
- });
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
+ );
1766
2055
  async function batchRelease(env) {
1767
2056
  const db = createDb(env.DB);
1768
2057
  const cache = new CacheService(env.KV);
@@ -1790,10 +2079,20 @@ async function batchRelease(env) {
1790
2079
 
1791
2080
  // src/routes/internal/batch-release.ts
1792
2081
  var batchReleaseRoute = new Hono();
1793
- batchReleaseRoute.post("/", internalMiddleware, async (c) => {
1794
- await batchRelease(c.env);
1795
- return c.json({ ok: true });
1796
- });
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
+ );
1797
2096
  function parseStartTimestamp(dateTime, startTime) {
1798
2097
  if (!dateTime) return null;
1799
2098
  const combined = startTime ? `${dateTime} ${startTime}` : dateTime;
@@ -1865,10 +2164,20 @@ async function reminderEmails(env) {
1865
2164
 
1866
2165
  // src/routes/internal/reminder-emails.ts
1867
2166
  var reminderEmailsRoute = new Hono();
1868
- reminderEmailsRoute.post("/", internalMiddleware, async (c) => {
1869
- await reminderEmails(c.env);
1870
- return c.json({ ok: true });
1871
- });
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
+ );
1872
2181
  var RENEWAL_WINDOW = 86400;
1873
2182
  async function renewWatches(env) {
1874
2183
  const db = createDb(env.DB);
@@ -1900,10 +2209,20 @@ async function renewWatches(env) {
1900
2209
 
1901
2210
  // src/routes/internal/renew-watches.ts
1902
2211
  var renewWatchesRoute = new Hono();
1903
- renewWatchesRoute.post("/", internalMiddleware, async (c) => {
1904
- await renewWatches(c.env);
1905
- return c.json({ ok: true });
1906
- });
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
+ );
1907
2226
  var ALLOWED_MIME_TYPES = /* @__PURE__ */ new Set([
1908
2227
  "image/jpeg",
1909
2228
  "image/png",
@@ -1913,30 +2232,50 @@ var ALLOWED_MIME_TYPES = /* @__PURE__ */ new Set([
1913
2232
  ]);
1914
2233
  var MAX_FILE_SIZE = 10 * 1024 * 1024;
1915
2234
  var uploadsRoute = new Hono();
1916
- uploadsRoute.get("/images/*", async (c) => {
1917
- const bucket = c.env.FILES;
1918
- if (!bucket) {
1919
- throw serviceUnavailable("File storage (R2) is not configured.");
1920
- }
1921
- const path = c.req.path.split("/uploads/images/")[1];
1922
- if (!path) throw notFound("Image");
1923
- const object = await bucket.get(path);
1924
- if (!object) throw notFound("Image");
1925
- const headers = {
1926
- etag: object.httpEtag,
1927
- // Cache at the edge/browser for 1 month
1928
- "Cache-Control": "public, max-age=2592000, immutable"
1929
- };
1930
- if (object.httpMetadata?.contentType) {
1931
- headers["Content-Type"] = object.httpMetadata.contentType;
1932
- }
1933
- if (object.httpMetadata?.cacheControl) {
1934
- 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);
1935
2266
  }
1936
- return c.body(object.body, 200, headers);
1937
- });
2267
+ );
1938
2268
  uploadsRoute.post(
1939
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
+ }),
1940
2279
  authMiddleware,
1941
2280
  adminMiddleware,
1942
2281
  async (c) => {
@@ -2021,16 +2360,33 @@ z.object({
2021
2360
  sortOrder: z.number().int().optional()
2022
2361
  });
2023
2362
  var themesRoute = new Hono();
2024
- themesRoute.get("/", async (c) => {
2025
- const db = createDb(c.env.DB);
2026
- const data = await db.select().from(themes).orderBy(asc(themes.sortOrder), asc(themes.createdAt));
2027
- return c.json({ data });
2028
- });
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
+ );
2029
2376
  themesRoute.post(
2030
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
+ }),
2031
2387
  authMiddleware,
2032
2388
  adminMiddleware,
2033
- zValidator("json", createThemeSchema),
2389
+ validator("json", createThemeSchema),
2034
2390
  async (c) => {
2035
2391
  const body = c.req.valid("json");
2036
2392
  const db = createDb(c.env.DB);
@@ -2048,6 +2404,15 @@ themesRoute.post(
2048
2404
  );
2049
2405
  themesRoute.patch(
2050
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
+ }),
2051
2416
  authMiddleware,
2052
2417
  adminMiddleware,
2053
2418
  async (c) => {
@@ -2070,13 +2435,26 @@ themesRoute.patch(
2070
2435
  }
2071
2436
  }
2072
2437
  );
2073
- themesRoute.delete("/:id", authMiddleware, adminMiddleware, async (c) => {
2074
- const { id } = c.req.param();
2075
- const db = createDb(c.env.DB);
2076
- const [deleted] = await db.delete(themes).where(eq(themes.id, id)).returning();
2077
- if (!deleted) throw notFound("Theme");
2078
- return c.body(null, 204);
2079
- });
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
+ );
2080
2458
  var createOrganizationSchema = z.object({
2081
2459
  name: z.string().min(1),
2082
2460
  acronym: z.string().min(1),
@@ -2084,16 +2462,33 @@ var createOrganizationSchema = z.object({
2084
2462
  link: z.string().url().nullable().optional()
2085
2463
  });
2086
2464
  var organizationsRoute = new Hono();
2087
- organizationsRoute.get("/", async (c) => {
2088
- const db = createDb(c.env.DB);
2089
- const data = await db.select().from(organizations);
2090
- return c.json({ data });
2091
- });
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
+ );
2092
2478
  organizationsRoute.post(
2093
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
+ }),
2094
2489
  authMiddleware,
2095
2490
  adminMiddleware,
2096
- zValidator("json", createOrganizationSchema),
2491
+ validator("json", createOrganizationSchema),
2097
2492
  async (c) => {
2098
2493
  const body = c.req.valid("json");
2099
2494
  const db = createDb(c.env.DB);
@@ -2110,6 +2505,15 @@ organizationsRoute.post(
2110
2505
  );
2111
2506
  organizationsRoute.patch(
2112
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
+ }),
2113
2517
  authMiddleware,
2114
2518
  adminMiddleware,
2115
2519
  async (c) => {
@@ -2128,13 +2532,26 @@ organizationsRoute.patch(
2128
2532
  }
2129
2533
  }
2130
2534
  );
2131
- organizationsRoute.delete("/:id", authMiddleware, adminMiddleware, async (c) => {
2132
- const { id } = c.req.param();
2133
- const db = createDb(c.env.DB);
2134
- const [deleted] = await db.delete(organizations).where(eq(organizations.id, id)).returning();
2135
- if (!deleted) throw notFound("Organization");
2136
- return c.body(null, 204);
2137
- });
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
+ );
2138
2555
 
2139
2556
  // src/app.ts
2140
2557
  function createApp(options = {}) {
@@ -2192,6 +2609,27 @@ function createApp(options = {}) {
2192
2609
  app2.route("/internal/batch-release", batchReleaseRoute);
2193
2610
  app2.route("/internal/reminder-emails", reminderEmailsRoute);
2194
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
+ );
2195
2633
  app2.onError(errorHandler);
2196
2634
  app2.notFound(
2197
2635
  (c) => c.json({ error: { code: "NOT_FOUND", message: "Route not found" } }, 404)