@access-dlsu/leapify 0.260602.1 → 0.260605.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/index.js CHANGED
@@ -1,6 +1,8 @@
1
- import { createTurnstileMiddleware, TURNSTILE_VERIFY_PATH, handleTurnstileVerify } from './chunk-WTA2QGY5.js';
1
+ import { createTurnstileMiddleware, TURNSTILE_VERIFY_PATH, handleTurnstileVerify } from './chunk-WEW5LGZC.js';
2
2
  import { __export } from './chunk-PZ5AY32C.js';
3
3
  import { Hono } from 'hono';
4
+ import { describeRoute, validator, openAPIRouteHandler } from 'hono-openapi';
5
+ import { swaggerUI } from '@hono/swagger-ui';
4
6
  import { cors } from 'hono/cors';
5
7
  import { drizzle } from 'drizzle-orm/d1';
6
8
  import { sqliteTable, integer, text, index, uniqueIndex } from 'drizzle-orm/sqlite-core';
@@ -9,7 +11,6 @@ import { createMiddleware } from 'hono/factory';
9
11
  import { betterAuth } from 'better-auth';
10
12
  import { drizzleAdapter } from 'better-auth/adapters/drizzle';
11
13
  import { bearer } from 'better-auth/plugins';
12
- import { zValidator } from '@hono/zod-validator';
13
14
  import { z } from 'zod';
14
15
 
15
16
  // src/lib/errors.ts
@@ -578,8 +579,6 @@ var internalMiddleware = createMiddleware(async (c, next) => {
578
579
  }
579
580
  return next();
580
581
  });
581
-
582
- // src/routes/health.ts
583
582
  var healthRoute = new Hono();
584
583
  async function probeResend(apiKey) {
585
584
  const start = Date.now();
@@ -681,69 +680,87 @@ async function probeGForms(serviceAccountJson) {
681
680
  };
682
681
  }
683
682
  }
684
- healthRoute.get("/", async (c) => {
685
- const env = c.env;
686
- const hasSes = Boolean(env.SES_REGION) && Boolean(env.SES_ACCESS_KEY_ID) && Boolean(env.SES_SECRET_ACCESS_KEY);
687
- const hasResend = Boolean(env.RESEND_API_KEY);
688
- let hasGForms = false;
689
- if (env.GFORMS_SERVICE_ACCOUNT_JSON) {
690
- try {
691
- const parsed = JSON.parse(env.GFORMS_SERVICE_ACCOUNT_JSON);
692
- hasGForms = Boolean(parsed.client_email && parsed.private_key);
693
- } catch {
683
+ healthRoute.get(
684
+ "/",
685
+ describeRoute({
686
+ tags: ["Health"],
687
+ summary: "Service health check",
688
+ responses: { 200: { description: "Health status of configured services" } }
689
+ }),
690
+ async (c) => {
691
+ const env = c.env;
692
+ const hasSes = Boolean(env.SES_REGION) && Boolean(env.SES_ACCESS_KEY_ID) && Boolean(env.SES_SECRET_ACCESS_KEY);
693
+ const hasResend = Boolean(env.RESEND_API_KEY);
694
+ let hasGForms = false;
695
+ if (env.GFORMS_SERVICE_ACCOUNT_JSON) {
696
+ try {
697
+ const parsed = JSON.parse(env.GFORMS_SERVICE_ACCOUNT_JSON);
698
+ hasGForms = Boolean(parsed.client_email && parsed.private_key);
699
+ } catch {
700
+ }
694
701
  }
695
- }
696
- const probes = [];
697
- if (hasSes) {
698
- probes.push(
699
- probeSes(env.SES_REGION, env.SES_ACCESS_KEY_ID, env.SES_SECRET_ACCESS_KEY).then(
700
- (h) => ["ses", h]
701
- )
702
- );
703
- }
704
- if (hasResend) {
705
- probes.push(
706
- probeResend(env.RESEND_API_KEY).then((h) => ["resend", h])
707
- );
708
- }
709
- if (hasGForms) {
710
- probes.push(
711
- probeGForms(env.GFORMS_SERVICE_ACCOUNT_JSON).then(
712
- (h) => ["gforms", h]
713
- )
714
- );
715
- }
716
- const results = await Promise.all(probes);
717
- const services = {};
718
- for (const [name, health] of results) {
719
- services[name] = health;
720
- }
721
- const allOk = results.length === 0 || results.every(([, h]) => h.ok);
722
- return c.json({
723
- data: {
724
- status: allOk ? "OK" : "DEGRADED",
725
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
726
- services
702
+ const probes = [];
703
+ if (hasSes) {
704
+ probes.push(
705
+ probeSes(env.SES_REGION, env.SES_ACCESS_KEY_ID, env.SES_SECRET_ACCESS_KEY).then(
706
+ (h) => ["ses", h]
707
+ )
708
+ );
727
709
  }
728
- });
729
- });
730
- healthRoute.post("/queue-burst", authMiddleware, adminMiddleware, async (c) => {
731
- if (!c.env.EMAIL_QUEUE) {
732
- return c.json({ error: "Queue binding missing" }, 400);
733
- }
734
- const batch = Array.from({ length: 100 }, (_, i) => ({
735
- body: {
736
- type: "audit_log",
737
- payload: {
738
- action: "queue_load_test",
739
- userId: "system",
740
- meta: { index: i, time: Date.now() }
710
+ if (hasResend) {
711
+ probes.push(
712
+ probeResend(env.RESEND_API_KEY).then((h) => ["resend", h])
713
+ );
714
+ }
715
+ if (hasGForms) {
716
+ probes.push(
717
+ probeGForms(env.GFORMS_SERVICE_ACCOUNT_JSON).then(
718
+ (h) => ["gforms", h]
719
+ )
720
+ );
721
+ }
722
+ const results = await Promise.all(probes);
723
+ const services = {};
724
+ for (const [name, health] of results) {
725
+ services[name] = health;
726
+ }
727
+ const allOk = results.length === 0 || results.every(([, h]) => h.ok);
728
+ return c.json({
729
+ data: {
730
+ status: allOk ? "OK" : "DEGRADED",
731
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
732
+ services
741
733
  }
734
+ });
735
+ }
736
+ );
737
+ healthRoute.post(
738
+ "/queue-burst",
739
+ describeRoute({
740
+ tags: ["Health"],
741
+ summary: "Queue load test (admin)",
742
+ responses: { 200: { description: "Items queued" } }
743
+ }),
744
+ authMiddleware,
745
+ adminMiddleware,
746
+ async (c) => {
747
+ if (!c.env.EMAIL_QUEUE) {
748
+ return c.json({ error: "Queue binding missing" }, 400);
742
749
  }
743
- }));
744
- await c.env.EMAIL_QUEUE.sendBatch(batch);
745
- return c.json({ status: "queued", count: 100 });
746
- });
750
+ const batch = Array.from({ length: 100 }, (_, i) => ({
751
+ body: {
752
+ type: "audit_log",
753
+ payload: {
754
+ action: "queue_load_test",
755
+ userId: "system",
756
+ meta: { index: i, time: Date.now() }
757
+ }
758
+ }
759
+ }));
760
+ await c.env.EMAIL_QUEUE.sendBatch(batch);
761
+ return c.json({ status: "queued", count: 100 });
762
+ }
763
+ );
747
764
 
748
765
  // src/services/cache.ts
749
766
  var CacheService = class {
@@ -1116,143 +1133,229 @@ var classesRoute = new Hono();
1116
1133
  function generateSlug(title) {
1117
1134
  return title.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
1118
1135
  }
1119
- classesRoute.get("/admin", authMiddleware, adminMiddleware, async (c) => {
1120
- const db = createDb(c.env.DB);
1121
- const data = await db.query.events.findMany({
1122
- with: { theme: true, organization: true },
1123
- orderBy: (e, { desc }) => [desc(e.createdAt)]
1124
- });
1125
- return c.json({ data });
1126
- });
1127
- classesRoute.post("/admin/publish", authMiddleware, adminMiddleware, async (c) => {
1128
- const body = await c.req.json();
1129
- const db = createDb(c.env.DB);
1130
- const cache = new CacheService(c.env.KV);
1131
- if (!body.ids?.length) {
1132
- return c.json({ error: "ids are required" }, 400);
1133
- }
1134
- if (body.releaseAt) {
1135
- await db.update(events).set({ releaseAt: body.releaseAt, status: "queued" }).where(
1136
- sql`${events.id} IN (${sql.join(
1137
- body.ids.map((id) => sql`${id}`),
1138
- sql`, `
1139
- )})`
1136
+ function serializeEvent(event) {
1137
+ if (!event) return event;
1138
+ const { dateTime, ...rest } = event;
1139
+ return { ...rest, date: dateTime };
1140
+ }
1141
+ function serializeEvents(events2) {
1142
+ return events2.map(serializeEvent);
1143
+ }
1144
+ classesRoute.get(
1145
+ "/admin",
1146
+ describeRoute({
1147
+ tags: ["Events"],
1148
+ summary: "List all events (admin)",
1149
+ responses: { 200: { description: "List of all events" } }
1150
+ }),
1151
+ authMiddleware,
1152
+ adminMiddleware,
1153
+ async (c) => {
1154
+ const db = createDb(c.env.DB);
1155
+ const data = await db.query.events.findMany({
1156
+ with: { theme: true, organization: true },
1157
+ orderBy: (e, { desc }) => [desc(e.createdAt)]
1158
+ });
1159
+ return c.json({ data: serializeEvents(data) });
1160
+ }
1161
+ );
1162
+ classesRoute.post(
1163
+ "/admin/publish",
1164
+ describeRoute({
1165
+ tags: ["Events"],
1166
+ summary: "Batch publish queued events",
1167
+ responses: {
1168
+ 200: { description: "Events published successfully" },
1169
+ 400: { description: "Missing event IDs" }
1170
+ }
1171
+ }),
1172
+ authMiddleware,
1173
+ adminMiddleware,
1174
+ async (c) => {
1175
+ const body = await c.req.json();
1176
+ const db = createDb(c.env.DB);
1177
+ const cache = new CacheService(c.env.KV);
1178
+ if (!body.ids?.length) {
1179
+ return c.json({ error: "ids are required" }, 400);
1180
+ }
1181
+ if (body.releaseAt) {
1182
+ await db.update(events).set({ releaseAt: body.releaseAt, status: "queued" }).where(
1183
+ sql`${events.id} IN (${sql.join(
1184
+ body.ids.map((id) => sql`${id}`),
1185
+ sql`, `
1186
+ )})`
1187
+ );
1188
+ } else {
1189
+ await db.update(events).set({ status: "published", publishedAt: sql`(unixepoch())` }).where(
1190
+ sql`${events.id} IN (${sql.join(
1191
+ body.ids.map((id) => sql`${id}`),
1192
+ sql`, `
1193
+ )})`
1194
+ );
1195
+ }
1196
+ await Promise.all([
1197
+ cache.del(EVENTS_LIST_KV_KEY),
1198
+ cache.del(EVENTS_ETAG_KV_KEY)
1199
+ ]);
1200
+ return c.json({ data: { updated: body.ids.length } });
1201
+ }
1202
+ );
1203
+ classesRoute.get(
1204
+ "/",
1205
+ describeRoute({
1206
+ tags: ["Events"],
1207
+ summary: "List published events",
1208
+ responses: { 200: { description: "List of published events with themes" } }
1209
+ }),
1210
+ eventsListRateLimit,
1211
+ async (c) => {
1212
+ const db = createDb(c.env.DB);
1213
+ const cache = new CacheService(c.env.KV);
1214
+ const [latest] = await db.select({ max: events.publishedAt }).from(events).where(eq(events.status, "published")).limit(1);
1215
+ const etag = await cache.getOrSet(
1216
+ EVENTS_ETAG_KV_KEY,
1217
+ () => cache.generateETag(String(latest?.max ?? "0")),
1218
+ 300
1219
+ );
1220
+ const ifNoneMatch = c.req.header("If-None-Match");
1221
+ if (ifNoneMatch === etag) {
1222
+ return c.body(null, 304);
1223
+ }
1224
+ const data = await cache.getOrSet(
1225
+ EVENTS_LIST_KV_KEY,
1226
+ () => db.query.events.findMany({
1227
+ where: eq(events.status, "published"),
1228
+ with: {
1229
+ theme: true,
1230
+ organization: true
1231
+ },
1232
+ columns: {
1233
+ id: true,
1234
+ slug: true,
1235
+ themeId: true,
1236
+ organizationId: true,
1237
+ title: true,
1238
+ venue: true,
1239
+ dateTime: true,
1240
+ price: true,
1241
+ backgroundImageUrl: true,
1242
+ classCode: true,
1243
+ startTime: true,
1244
+ endTime: true,
1245
+ registrationClosesAt: true,
1246
+ isSpotlight: true,
1247
+ maxSlots: true,
1248
+ registeredSlots: true,
1249
+ gformsUrl: true,
1250
+ gformsEditorUrl: true,
1251
+ publishedAt: true
1252
+ }
1253
+ }),
1254
+ EVENTS_LIST_TTL
1140
1255
  );
1141
- } else {
1142
- await db.update(events).set({ status: "published", publishedAt: sql`(unixepoch())` }).where(
1143
- sql`${events.id} IN (${sql.join(
1144
- body.ids.map((id) => sql`${id}`),
1145
- sql`, `
1146
- )})`
1256
+ c.header("ETag", etag);
1257
+ c.header(
1258
+ "Cache-Control",
1259
+ "public, max-age=604800, stale-while-revalidate=86400"
1147
1260
  );
1261
+ return c.json({ data: serializeEvents(data) });
1148
1262
  }
1149
- await Promise.all([
1150
- cache.del(EVENTS_LIST_KV_KEY),
1151
- cache.del(EVENTS_ETAG_KV_KEY)
1152
- ]);
1153
- return c.json({ data: { updated: body.ids.length } });
1154
- });
1155
- classesRoute.get("/", eventsListRateLimit, async (c) => {
1156
- const db = createDb(c.env.DB);
1157
- const cache = new CacheService(c.env.KV);
1158
- const [latest] = await db.select({ max: events.publishedAt }).from(events).where(eq(events.status, "published")).limit(1);
1159
- const etag = await cache.getOrSet(
1160
- EVENTS_ETAG_KV_KEY,
1161
- () => cache.generateETag(String(latest?.max ?? "0")),
1162
- 300
1163
- );
1164
- const ifNoneMatch = c.req.header("If-None-Match");
1165
- if (ifNoneMatch === etag) {
1166
- return c.body(null, 304);
1167
- }
1168
- const data = await cache.getOrSet(
1169
- EVENTS_LIST_KV_KEY,
1170
- () => db.query.events.findMany({
1171
- where: eq(events.status, "published"),
1263
+ );
1264
+ classesRoute.get(
1265
+ "/:slug",
1266
+ describeRoute({
1267
+ tags: ["Events"],
1268
+ summary: "Get event by slug",
1269
+ responses: {
1270
+ 200: { description: "Event details" },
1271
+ 404: { description: "Event not found" }
1272
+ }
1273
+ }),
1274
+ async (c) => {
1275
+ const { slug } = c.req.param();
1276
+ const db = createDb(c.env.DB);
1277
+ const event = await db.query.events.findFirst({
1278
+ where: and(eq(events.slug, slug), eq(events.status, "published")),
1172
1279
  with: {
1173
- theme: true,
1174
- organization: true
1175
- },
1176
- columns: {
1177
- id: true,
1178
- slug: true,
1179
- themeId: true,
1180
- organizationId: true,
1181
- title: true,
1182
- venue: true,
1183
- dateTime: true,
1184
- price: true,
1185
- backgroundImageUrl: true,
1186
- classCode: true,
1187
- startTime: true,
1188
- endTime: true,
1189
- registrationClosesAt: true,
1190
- isSpotlight: true,
1191
- maxSlots: true,
1192
- registeredSlots: true,
1193
- gformsUrl: true,
1194
- gformsEditorUrl: true,
1195
- publishedAt: true
1280
+ theme: true
1196
1281
  }
1197
- }),
1198
- EVENTS_LIST_TTL
1199
- );
1200
- c.header("ETag", etag);
1201
- c.header(
1202
- "Cache-Control",
1203
- "public, max-age=604800, stale-while-revalidate=86400"
1204
- );
1205
- return c.json({ data });
1206
- });
1207
- classesRoute.get("/:slug", async (c) => {
1208
- const { slug } = c.req.param();
1209
- const db = createDb(c.env.DB);
1210
- const event = await db.query.events.findFirst({
1211
- where: and(eq(events.slug, slug), eq(events.status, "published")),
1212
- with: {
1213
- theme: true
1282
+ });
1283
+ if (!event) throw notFound("Event");
1284
+ return c.json({ data: serializeEvent(event) });
1285
+ }
1286
+ );
1287
+ classesRoute.get(
1288
+ "/:slug/slots",
1289
+ describeRoute({
1290
+ tags: ["Events"],
1291
+ summary: "Get event slot availability",
1292
+ responses: {
1293
+ 200: { description: "Slot availability info" },
1294
+ 404: { description: "Event not found" }
1214
1295
  }
1215
- });
1216
- if (!event) throw notFound("Event");
1217
- return c.json({ data: event });
1218
- });
1219
- classesRoute.get("/:slug/slots", eventsSlotsRateLimit, async (c) => {
1220
- const { slug } = c.req.param();
1221
- const db = createDb(c.env.DB);
1222
- const cache = new CacheService(c.env.KV);
1223
- const slotsService = new SlotsService(db, cache);
1224
- const info = await slotsService.getSlots(slug);
1225
- if (!info) throw notFound("Event");
1226
- c.header("Cache-Control", "public, max-age=5, stale-while-revalidate=5");
1227
- return c.json({ data: info });
1228
- });
1229
- classesRoute.post("/:slug/reconcile", authMiddleware, adminMiddleware, async (c) => {
1230
- const { slug } = c.req.param();
1231
- const db = createDb(c.env.DB);
1232
- const cache = new CacheService(c.env.KV);
1233
- const gforms = new GFormsService(c.env.GFORMS_SERVICE_ACCOUNT_JSON);
1234
- const slots = new SlotsService(db, cache);
1235
- const event = await db.query.events.findFirst({
1236
- where: eq(events.slug, slug),
1237
- columns: { gformsId: true }
1238
- });
1239
- if (!event) throw notFound("Event");
1240
- if (!event.gformsId) return c.json({ error: "No gformsId set for this event" }, 400);
1241
- try {
1242
- const googleCount = await gforms.getExactResponseCount(event.gformsId);
1243
- await slots.correctCount(slug, googleCount);
1244
- return c.json({ data: { registeredSlots: googleCount } });
1245
- } catch (err) {
1246
- const message = err?.message ?? "Failed to fetch from Google Forms API";
1247
- return c.json({ error: { code: "GFORMS_API_ERROR", message } }, 502);
1296
+ }),
1297
+ eventsSlotsRateLimit,
1298
+ async (c) => {
1299
+ const { slug } = c.req.param();
1300
+ const db = createDb(c.env.DB);
1301
+ const cache = new CacheService(c.env.KV);
1302
+ const slotsService = new SlotsService(db, cache);
1303
+ const info = await slotsService.getSlots(slug);
1304
+ if (!info) throw notFound("Event");
1305
+ c.header("Cache-Control", "public, max-age=5, stale-while-revalidate=5");
1306
+ return c.json({ data: info });
1248
1307
  }
1249
- });
1308
+ );
1309
+ classesRoute.post(
1310
+ "/:slug/reconcile",
1311
+ describeRoute({
1312
+ tags: ["Events"],
1313
+ summary: "Reconcile event slot count with Google Forms",
1314
+ responses: {
1315
+ 200: { description: "Slot count reconciled" },
1316
+ 400: { description: "No gformsId set" },
1317
+ 404: { description: "Event not found" },
1318
+ 502: { description: "Google Forms API error" }
1319
+ }
1320
+ }),
1321
+ authMiddleware,
1322
+ adminMiddleware,
1323
+ async (c) => {
1324
+ const { slug } = c.req.param();
1325
+ const db = createDb(c.env.DB);
1326
+ const cache = new CacheService(c.env.KV);
1327
+ const gforms = new GFormsService(c.env.GFORMS_SERVICE_ACCOUNT_JSON);
1328
+ const slots = new SlotsService(db, cache);
1329
+ const event = await db.query.events.findFirst({
1330
+ where: eq(events.slug, slug),
1331
+ columns: { gformsId: true }
1332
+ });
1333
+ if (!event) throw notFound("Event");
1334
+ if (!event.gformsId) return c.json({ error: "No gformsId set for this event" }, 400);
1335
+ try {
1336
+ const googleCount = await gforms.getExactResponseCount(event.gformsId);
1337
+ await slots.correctCount(slug, googleCount);
1338
+ return c.json({ data: { registeredSlots: googleCount } });
1339
+ } catch (err) {
1340
+ const message = err?.message ?? "Failed to fetch from Google Forms API";
1341
+ return c.json({ error: { code: "GFORMS_API_ERROR", message } }, 502);
1342
+ }
1343
+ }
1344
+ );
1250
1345
  classesRoute.post(
1251
1346
  "/",
1347
+ describeRoute({
1348
+ tags: ["Events"],
1349
+ summary: "Create a new event",
1350
+ responses: {
1351
+ 201: { description: "Event created successfully" },
1352
+ 422: { description: "Validation error" }
1353
+ }
1354
+ }),
1252
1355
  authMiddleware,
1253
1356
  adminMiddleware,
1254
1357
  adminEventsRateLimit,
1255
- zValidator("json", createEventSchema),
1358
+ validator("json", createEventSchema),
1256
1359
  async (c) => {
1257
1360
  const body = c.req.valid("json");
1258
1361
  const db = createDb(c.env.DB);
@@ -1282,161 +1385,287 @@ classesRoute.post(
1282
1385
  cache.del(EVENTS_LIST_KV_KEY),
1283
1386
  cache.del(EVENTS_ETAG_KV_KEY)
1284
1387
  ]);
1285
- return c.json({ data: created }, 201);
1388
+ return c.json({ data: serializeEvent(created) }, 201);
1389
+ }
1390
+ );
1391
+ classesRoute.patch(
1392
+ "/:slug",
1393
+ describeRoute({
1394
+ tags: ["Events"],
1395
+ summary: "Update an event",
1396
+ responses: {
1397
+ 200: { description: "Event updated successfully" },
1398
+ 404: { description: "Event not found" }
1399
+ }
1400
+ }),
1401
+ authMiddleware,
1402
+ adminMiddleware,
1403
+ async (c) => {
1404
+ const { slug } = c.req.param();
1405
+ const body = await c.req.json();
1406
+ const db = createDb(c.env.DB);
1407
+ const cache = new CacheService(c.env.KV);
1408
+ let newSlug;
1409
+ if (body.title) {
1410
+ newSlug = generateSlug(body.title);
1411
+ }
1412
+ const [updated] = await db.update(events).set(newSlug ? { ...body, slug: newSlug } : body).where(eq(events.slug, slug)).returning();
1413
+ if (!updated) throw notFound("Event");
1414
+ await Promise.all([
1415
+ cache.del(EVENTS_LIST_KV_KEY),
1416
+ cache.del(EVENTS_ETAG_KV_KEY)
1417
+ ]);
1418
+ return c.json({ data: serializeEvent(updated) });
1419
+ }
1420
+ );
1421
+ classesRoute.delete(
1422
+ "/:slug",
1423
+ describeRoute({
1424
+ tags: ["Events"],
1425
+ summary: "Delete an event",
1426
+ responses: {
1427
+ 204: { description: "Event deleted" },
1428
+ 404: { description: "Event not found" }
1429
+ }
1430
+ }),
1431
+ authMiddleware,
1432
+ adminMiddleware,
1433
+ async (c) => {
1434
+ const { slug } = c.req.param();
1435
+ const db = createDb(c.env.DB);
1436
+ const cache = new CacheService(c.env.KV);
1437
+ const [deleted] = await db.delete(events).where(eq(events.slug, slug)).returning();
1438
+ if (!deleted) throw notFound("Event");
1439
+ await Promise.all([
1440
+ cache.del(EVENTS_LIST_KV_KEY),
1441
+ cache.del(EVENTS_ETAG_KV_KEY)
1442
+ ]);
1443
+ return c.body(null, 204);
1286
1444
  }
1287
1445
  );
1288
- classesRoute.patch("/:slug", authMiddleware, adminMiddleware, async (c) => {
1289
- const { slug } = c.req.param();
1290
- const body = await c.req.json();
1291
- const db = createDb(c.env.DB);
1292
- const cache = new CacheService(c.env.KV);
1293
- let newSlug;
1294
- if (body.title) {
1295
- newSlug = generateSlug(body.title);
1296
- }
1297
- const [updated] = await db.update(events).set(newSlug ? { ...body, slug: newSlug } : body).where(eq(events.slug, slug)).returning();
1298
- if (!updated) throw notFound("Event");
1299
- await Promise.all([
1300
- cache.del(EVENTS_LIST_KV_KEY),
1301
- cache.del(EVENTS_ETAG_KV_KEY)
1302
- ]);
1303
- return c.json({ data: updated });
1304
- });
1305
- classesRoute.delete("/:slug", authMiddleware, adminMiddleware, async (c) => {
1306
- const { slug } = c.req.param();
1307
- const db = createDb(c.env.DB);
1308
- const cache = new CacheService(c.env.KV);
1309
- const [deleted] = await db.delete(events).where(eq(events.slug, slug)).returning();
1310
- if (!deleted) throw notFound("Event");
1311
- await Promise.all([
1312
- cache.del(EVENTS_LIST_KV_KEY),
1313
- cache.del(EVENTS_ETAG_KV_KEY)
1314
- ]);
1315
- return c.body(null, 204);
1316
- });
1317
1446
  var VALID_ROLES = ["student", "admin", "super_admin"];
1318
1447
  var usersRoute = new Hono();
1319
- usersRoute.get("/", authMiddleware, adminMiddleware, async (c) => {
1320
- const db = createDb(c.env.DB);
1321
- const data = await db.select().from(users);
1322
- return c.json({ data });
1323
- });
1324
- usersRoute.patch("/:id/role", authMiddleware, adminMiddleware, async (c) => {
1325
- const { id } = c.req.param();
1326
- const { role } = await c.req.json();
1327
- if (!role || !VALID_ROLES.includes(role)) {
1328
- throw badRequest("Role must be 'student', 'admin', or 'super_admin'.");
1329
- }
1330
- const db = createDb(c.env.DB);
1331
- const [updated] = await db.update(users).set({ role }).where(eq(users.id, id)).returning();
1332
- if (!updated) throw notFound("User");
1333
- return c.json({ data: updated });
1334
- });
1335
- usersRoute.post("/by-email", authMiddleware, adminMiddleware, async (c) => {
1336
- const { email, role } = await c.req.json();
1337
- if (!email || !role || !VALID_ROLES.includes(role)) {
1338
- throw badRequest("Email and valid role ('student', 'admin', 'super_admin') are required.");
1339
- }
1340
- const db = createDb(c.env.DB);
1341
- const existing = await db.query.users.findFirst({
1342
- where: eq(users.email, email)
1343
- });
1344
- if (existing) {
1345
- const [updated] = await db.update(users).set({ role }).where(eq(users.email, email)).returning();
1448
+ usersRoute.get(
1449
+ "/",
1450
+ describeRoute({
1451
+ tags: ["Users"],
1452
+ summary: "List all users (admin)",
1453
+ responses: { 200: { description: "List of users" } }
1454
+ }),
1455
+ authMiddleware,
1456
+ adminMiddleware,
1457
+ async (c) => {
1458
+ const db = createDb(c.env.DB);
1459
+ const data = await db.select().from(users);
1460
+ return c.json({ data });
1461
+ }
1462
+ );
1463
+ usersRoute.patch(
1464
+ "/:id/role",
1465
+ describeRoute({
1466
+ tags: ["Users"],
1467
+ summary: "Change user role (admin)",
1468
+ responses: {
1469
+ 200: { description: "Role updated" },
1470
+ 400: { description: "Invalid role" },
1471
+ 404: { description: "User not found" }
1472
+ }
1473
+ }),
1474
+ authMiddleware,
1475
+ adminMiddleware,
1476
+ async (c) => {
1477
+ const { id } = c.req.param();
1478
+ const { role } = await c.req.json();
1479
+ if (!role || !VALID_ROLES.includes(role)) {
1480
+ throw badRequest("Role must be 'student', 'admin', or 'super_admin'.");
1481
+ }
1482
+ const db = createDb(c.env.DB);
1483
+ const [updated] = await db.update(users).set({ role }).where(eq(users.id, id)).returning();
1484
+ if (!updated) throw notFound("User");
1346
1485
  return c.json({ data: updated });
1347
1486
  }
1348
- const [created] = await db.insert(users).values({ betterAuthId: `pending:${email}`, email, name: email.split("@")[0], role }).returning();
1349
- return c.json({ data: created }, 201);
1350
- });
1351
- usersRoute.get("/me", optionalAuthMiddleware, async (c) => {
1352
- const user = c.get("user");
1353
- if (!user) return c.json({ data: null });
1354
- const db = createDb(c.env.DB);
1355
- const profile = await db.query.users.findFirst({
1356
- where: eq(users.id, user.dbId)
1357
- });
1358
- if (!profile) return c.json({ data: null });
1359
- const auth = await db.query.authUser.findFirst({
1360
- where: eq(authUser.id, profile.betterAuthId),
1361
- columns: { image: true }
1362
- });
1363
- return c.json({ data: { ...profile, image: auth?.image ?? null } });
1364
- });
1365
- usersRoute.get("/me/bookmarks", optionalAuthMiddleware, async (c) => {
1366
- const user = c.get("user");
1367
- if (!user) return c.json({ data: [] });
1368
- const db = createDb(c.env.DB);
1369
- const rows = await db.query.bookmarks.findMany({
1370
- where: eq(bookmarks.userId, user.dbId),
1371
- with: { event: true }
1372
- });
1373
- const data = rows.map((r) => ({ bookmarkedAt: r.createdAt, event: r.event }));
1374
- return c.json({ data });
1375
- });
1376
- usersRoute.post("/me/bookmarks/:eventId", authMiddleware, bookmarksRateLimit, async (c) => {
1377
- const { eventId } = c.req.param();
1378
- const user = c.get("user");
1379
- const db = createDb(c.env.DB);
1380
- const event = await db.query.events.findFirst({
1381
- where: eq(events.id, eventId),
1382
- columns: { id: true }
1383
- });
1384
- if (!event) throw notFound("Event");
1385
- const inserted = await db.insert(bookmarks).values({ userId: user.dbId, eventId }).onConflictDoNothing({ target: [bookmarks.userId, bookmarks.eventId] }).returning();
1386
- if (inserted.length > 0) {
1387
- return c.json({ data: { bookmarked: true } }, 201);
1487
+ );
1488
+ usersRoute.post(
1489
+ "/by-email",
1490
+ describeRoute({
1491
+ tags: ["Users"],
1492
+ summary: "Find or create user by email (admin)",
1493
+ responses: {
1494
+ 200: { description: "User updated" },
1495
+ 201: { description: "User created" },
1496
+ 400: { description: "Invalid email or role" }
1497
+ }
1498
+ }),
1499
+ authMiddleware,
1500
+ adminMiddleware,
1501
+ async (c) => {
1502
+ const { email, role } = await c.req.json();
1503
+ if (!email || !role || !VALID_ROLES.includes(role)) {
1504
+ throw badRequest("Email and valid role ('student', 'admin', 'super_admin') are required.");
1505
+ }
1506
+ const db = createDb(c.env.DB);
1507
+ const existing = await db.query.users.findFirst({
1508
+ where: eq(users.email, email)
1509
+ });
1510
+ if (existing) {
1511
+ const [updated] = await db.update(users).set({ role }).where(eq(users.email, email)).returning();
1512
+ return c.json({ data: updated });
1513
+ }
1514
+ const [created] = await db.insert(users).values({ betterAuthId: `pending:${email}`, email, name: email.split("@")[0], role }).returning();
1515
+ return c.json({ data: created }, 201);
1388
1516
  }
1389
- await db.delete(bookmarks).where(and(eq(bookmarks.userId, user.dbId), eq(bookmarks.eventId, eventId)));
1390
- return c.json({ data: { bookmarked: false } }, 200);
1391
- });
1392
- usersRoute.delete("/me/bookmarks/:eventId", authMiddleware, async (c) => {
1393
- const { eventId } = c.req.param();
1394
- const user = c.get("user");
1395
- const db = createDb(c.env.DB);
1396
- await db.delete(bookmarks).where(
1397
- and(eq(bookmarks.userId, user.dbId), eq(bookmarks.eventId, eventId))
1398
- );
1399
- return c.json({ data: { bookmarked: false } });
1400
- });
1401
- var siteConfigRoute = new Hono();
1402
- siteConfigRoute.get("/", async (c) => {
1403
- const db = createDb(c.env.DB);
1404
- const rows = await db.query.siteConfig.findMany();
1405
- const config = Object.fromEntries(
1406
- rows.map((r) => [r.key, JSON.parse(r.value)])
1407
- );
1408
- return c.json({
1409
- data: {
1410
- comingSoonUntil: config.coming_soon_until ?? null,
1411
- siteEndsAt: config.site_ends_at ?? null,
1412
- siteName: config.site_name ?? null,
1413
- registrationGloballyOpen: config.registration_globally_open ?? true,
1414
- maintenanceMode: config.maintenance_mode ?? false,
1415
- allowedOrigins: config.allowed_origins ?? null,
1416
- now: Math.floor(Date.now() / 1e3)
1517
+ );
1518
+ usersRoute.get(
1519
+ "/me",
1520
+ describeRoute({
1521
+ tags: ["Users"],
1522
+ summary: "Get current user profile",
1523
+ responses: { 200: { description: "Current user profile or null" } }
1524
+ }),
1525
+ optionalAuthMiddleware,
1526
+ async (c) => {
1527
+ const user = c.get("user");
1528
+ if (!user) return c.json({ data: null });
1529
+ const db = createDb(c.env.DB);
1530
+ const profile = await db.query.users.findFirst({
1531
+ where: eq(users.id, user.dbId)
1532
+ });
1533
+ if (!profile) return c.json({ data: null });
1534
+ const auth = await db.query.authUser.findFirst({
1535
+ where: eq(authUser.id, profile.betterAuthId),
1536
+ columns: { image: true }
1537
+ });
1538
+ return c.json({ data: { ...profile, image: auth?.image ?? null } });
1539
+ }
1540
+ );
1541
+ usersRoute.get(
1542
+ "/me/bookmarks",
1543
+ describeRoute({
1544
+ tags: ["Users"],
1545
+ summary: "Get current user's bookmarks",
1546
+ responses: { 200: { description: "List of bookmarked events" } }
1547
+ }),
1548
+ optionalAuthMiddleware,
1549
+ async (c) => {
1550
+ const user = c.get("user");
1551
+ if (!user) return c.json({ data: [] });
1552
+ const db = createDb(c.env.DB);
1553
+ const rows = await db.query.bookmarks.findMany({
1554
+ where: eq(bookmarks.userId, user.dbId),
1555
+ with: { event: true }
1556
+ });
1557
+ const data = rows.map((r) => ({ bookmarkedAt: r.createdAt, event: r.event }));
1558
+ return c.json({ data });
1559
+ }
1560
+ );
1561
+ usersRoute.post(
1562
+ "/me/bookmarks/:eventId",
1563
+ describeRoute({
1564
+ tags: ["Users"],
1565
+ summary: "Toggle bookmark for an event",
1566
+ responses: {
1567
+ 201: { description: "Bookmark created" },
1568
+ 200: { description: "Bookmark removed" },
1569
+ 404: { description: "Event not found" }
1417
1570
  }
1418
- });
1419
- });
1420
- siteConfigRoute.patch("/:key", authMiddleware, adminMiddleware, async (c) => {
1421
- const key = c.req.param("key");
1422
- const { value } = await c.req.json();
1423
- if (key === "allowed_origins") {
1571
+ }),
1572
+ authMiddleware,
1573
+ bookmarksRateLimit,
1574
+ async (c) => {
1575
+ const { eventId } = c.req.param();
1424
1576
  const user = c.get("user");
1425
- if (!user || user.role !== "super_admin") {
1426
- throw forbidden("Super Admin access required to change allowed origins");
1577
+ const db = createDb(c.env.DB);
1578
+ const event = await db.query.events.findFirst({
1579
+ where: eq(events.id, eventId),
1580
+ columns: { id: true }
1581
+ });
1582
+ if (!event) throw notFound("Event");
1583
+ const inserted = await db.insert(bookmarks).values({ userId: user.dbId, eventId }).onConflictDoNothing({ target: [bookmarks.userId, bookmarks.eventId] }).returning();
1584
+ if (inserted.length > 0) {
1585
+ return c.json({ data: { bookmarked: true } }, 201);
1427
1586
  }
1587
+ await db.delete(bookmarks).where(and(eq(bookmarks.userId, user.dbId), eq(bookmarks.eventId, eventId)));
1588
+ return c.json({ data: { bookmarked: false } }, 200);
1428
1589
  }
1429
- const db = createDb(c.env.DB);
1430
- const now = Math.floor(Date.now() / 1e3);
1431
- await db.insert(siteConfig).values({ key, value: JSON.stringify(value), updatedAt: now }).onConflictDoUpdate({
1432
- target: siteConfig.key,
1433
- set: { value: JSON.stringify(value), updatedAt: now }
1434
- });
1435
- await c.env.KV.put(`config:${key}`, JSON.stringify(value), {
1436
- expirationTtl: 86400
1437
- });
1438
- return c.json({ data: { key, value } });
1439
- });
1590
+ );
1591
+ usersRoute.delete(
1592
+ "/me/bookmarks/:eventId",
1593
+ describeRoute({
1594
+ tags: ["Users"],
1595
+ summary: "Remove bookmark for an event",
1596
+ responses: { 200: { description: "Bookmark removed" } }
1597
+ }),
1598
+ authMiddleware,
1599
+ async (c) => {
1600
+ const { eventId } = c.req.param();
1601
+ const user = c.get("user");
1602
+ const db = createDb(c.env.DB);
1603
+ await db.delete(bookmarks).where(
1604
+ and(eq(bookmarks.userId, user.dbId), eq(bookmarks.eventId, eventId))
1605
+ );
1606
+ return c.json({ data: { bookmarked: false } });
1607
+ }
1608
+ );
1609
+ var siteConfigRoute = new Hono();
1610
+ siteConfigRoute.get(
1611
+ "/",
1612
+ describeRoute({
1613
+ tags: ["Site Config"],
1614
+ summary: "Get public site configuration",
1615
+ responses: { 200: { description: "Site configuration values" } }
1616
+ }),
1617
+ async (c) => {
1618
+ const db = createDb(c.env.DB);
1619
+ const rows = await db.query.siteConfig.findMany();
1620
+ const config = Object.fromEntries(
1621
+ rows.map((r) => [r.key, JSON.parse(r.value)])
1622
+ );
1623
+ return c.json({
1624
+ data: {
1625
+ comingSoonUntil: config.coming_soon_until ?? null,
1626
+ siteEndsAt: config.site_ends_at ?? null,
1627
+ siteName: config.site_name ?? null,
1628
+ registrationGloballyOpen: config.registration_globally_open ?? true,
1629
+ maintenanceMode: config.maintenance_mode ?? false,
1630
+ allowedOrigins: config.allowed_origins ?? null,
1631
+ now: Math.floor(Date.now() / 1e3)
1632
+ }
1633
+ });
1634
+ }
1635
+ );
1636
+ siteConfigRoute.patch(
1637
+ "/:key",
1638
+ describeRoute({
1639
+ tags: ["Site Config"],
1640
+ summary: "Update a site configuration key (admin)",
1641
+ responses: {
1642
+ 200: { description: "Config updated" },
1643
+ 403: { description: "Super admin required for this key" }
1644
+ }
1645
+ }),
1646
+ authMiddleware,
1647
+ adminMiddleware,
1648
+ async (c) => {
1649
+ const key = c.req.param("key");
1650
+ const { value } = await c.req.json();
1651
+ if (key === "allowed_origins") {
1652
+ const user = c.get("user");
1653
+ if (!user || user.role !== "super_admin") {
1654
+ throw forbidden("Super Admin access required to change allowed origins");
1655
+ }
1656
+ }
1657
+ const db = createDb(c.env.DB);
1658
+ const now = Math.floor(Date.now() / 1e3);
1659
+ await db.insert(siteConfig).values({ key, value: JSON.stringify(value), updatedAt: now }).onConflictDoUpdate({
1660
+ target: siteConfig.key,
1661
+ set: { value: JSON.stringify(value), updatedAt: now }
1662
+ });
1663
+ await c.env.KV.put(`config:${key}`, JSON.stringify(value), {
1664
+ expirationTtl: 86400
1665
+ });
1666
+ return c.json({ data: { key, value } });
1667
+ }
1668
+ );
1440
1669
  var FAQS_KV_KEY = "faqs:active";
1441
1670
  var FAQS_TTL = 600;
1442
1671
  var faqSchema = z.object({
@@ -1446,23 +1675,39 @@ var faqSchema = z.object({
1446
1675
  sortOrder: z.number().int().default(0)
1447
1676
  });
1448
1677
  var faqsRoute = new Hono();
1449
- faqsRoute.get("/", async (c) => {
1450
- const db = createDb(c.env.DB);
1451
- const cache = new CacheService(c.env.KV);
1452
- const data = await cache.getOrSet(
1453
- FAQS_KV_KEY,
1454
- () => db.query.faqs.findMany({
1455
- orderBy: (t, { asc: asc2 }) => [asc2(t.sortOrder), asc2(t.createdAt)]
1456
- }),
1457
- FAQS_TTL
1458
- );
1459
- return c.json({ data });
1460
- });
1678
+ faqsRoute.get(
1679
+ "/",
1680
+ describeRoute({
1681
+ tags: ["FAQs"],
1682
+ summary: "List all active FAQs",
1683
+ responses: { 200: { description: "List of FAQs" } }
1684
+ }),
1685
+ async (c) => {
1686
+ const db = createDb(c.env.DB);
1687
+ const cache = new CacheService(c.env.KV);
1688
+ const data = await cache.getOrSet(
1689
+ FAQS_KV_KEY,
1690
+ () => db.query.faqs.findMany({
1691
+ orderBy: (t, { asc: asc2 }) => [asc2(t.sortOrder), asc2(t.createdAt)]
1692
+ }),
1693
+ FAQS_TTL
1694
+ );
1695
+ return c.json({ data });
1696
+ }
1697
+ );
1461
1698
  faqsRoute.post(
1462
1699
  "/",
1700
+ describeRoute({
1701
+ tags: ["FAQs"],
1702
+ summary: "Create a new FAQ (admin)",
1703
+ responses: {
1704
+ 201: { description: "FAQ created" },
1705
+ 422: { description: "Validation error" }
1706
+ }
1707
+ }),
1463
1708
  authMiddleware,
1464
1709
  adminMiddleware,
1465
- zValidator("json", faqSchema),
1710
+ validator("json", faqSchema),
1466
1711
  async (c) => {
1467
1712
  const body = c.req.valid("json");
1468
1713
  const db = createDb(c.env.DB);
@@ -1472,65 +1717,105 @@ faqsRoute.post(
1472
1717
  return c.json({ data: created }, 201);
1473
1718
  }
1474
1719
  );
1475
- faqsRoute.patch("/:id", authMiddleware, adminMiddleware, async (c) => {
1476
- const { id } = c.req.param();
1477
- const body = await c.req.json();
1478
- const db = createDb(c.env.DB);
1479
- const cache = new CacheService(c.env.KV);
1480
- const now = Math.floor(Date.now() / 1e3);
1481
- const [updated] = await db.update(faqs).set({ ...body, updatedAt: now }).where(eq(faqs.id, id)).returning();
1482
- if (!updated) throw notFound("FAQ");
1483
- await cache.del(FAQS_KV_KEY);
1484
- return c.json({ data: updated });
1485
- });
1486
- faqsRoute.delete("/:id", authMiddleware, adminMiddleware, async (c) => {
1487
- const { id } = c.req.param();
1488
- const db = createDb(c.env.DB);
1489
- const cache = new CacheService(c.env.KV);
1490
- const [deleted] = await db.delete(faqs).where(eq(faqs.id, id)).returning();
1491
- if (!deleted) throw notFound("FAQ");
1492
- await cache.del(FAQS_KV_KEY);
1493
- return c.json({ data: { deleted: true } });
1494
- });
1495
- var gformsWebhookRoute = new Hono();
1496
- gformsWebhookRoute.post("/", internalMiddleware, async (c) => {
1497
- const rawBody = await c.req.text();
1498
- const signature = c.req.header("X-Goog-Signature");
1499
- if (signature) {
1500
- const isValid = await verifyGoogSignature(
1501
- rawBody,
1502
- signature,
1503
- c.env.GFORMS_WEBHOOK_SECRET
1504
- );
1505
- if (!isValid) {
1506
- return c.json({ error: "Invalid signature" }, 403);
1720
+ faqsRoute.patch(
1721
+ "/:id",
1722
+ describeRoute({
1723
+ tags: ["FAQs"],
1724
+ summary: "Update an FAQ (admin)",
1725
+ responses: {
1726
+ 200: { description: "FAQ updated" },
1727
+ 404: { description: "FAQ not found" }
1728
+ }
1729
+ }),
1730
+ authMiddleware,
1731
+ adminMiddleware,
1732
+ async (c) => {
1733
+ const { id } = c.req.param();
1734
+ const body = await c.req.json();
1735
+ const db = createDb(c.env.DB);
1736
+ const cache = new CacheService(c.env.KV);
1737
+ const now = Math.floor(Date.now() / 1e3);
1738
+ const [updated] = await db.update(faqs).set({ ...body, updatedAt: now }).where(eq(faqs.id, id)).returning();
1739
+ if (!updated) throw notFound("FAQ");
1740
+ await cache.del(FAQS_KV_KEY);
1741
+ return c.json({ data: updated });
1742
+ }
1743
+ );
1744
+ faqsRoute.delete(
1745
+ "/:id",
1746
+ describeRoute({
1747
+ tags: ["FAQs"],
1748
+ summary: "Delete an FAQ (admin)",
1749
+ responses: {
1750
+ 200: { description: "FAQ deleted" },
1751
+ 404: { description: "FAQ not found" }
1507
1752
  }
1753
+ }),
1754
+ authMiddleware,
1755
+ adminMiddleware,
1756
+ async (c) => {
1757
+ const { id } = c.req.param();
1758
+ const db = createDb(c.env.DB);
1759
+ const cache = new CacheService(c.env.KV);
1760
+ const [deleted] = await db.delete(faqs).where(eq(faqs.id, id)).returning();
1761
+ if (!deleted) throw notFound("FAQ");
1762
+ await cache.del(FAQS_KV_KEY);
1763
+ return c.json({ data: { deleted: true } });
1508
1764
  }
1509
- let payload;
1510
- try {
1511
- payload = JSON.parse(rawBody);
1512
- } catch {
1513
- return c.json({ error: "Invalid payload" }, 400);
1514
- }
1515
- const { formId } = payload;
1516
- if (!formId) return c.json({ error: "Missing formId" }, 400);
1517
- const db = createDb(c.env.DB);
1518
- const cache = new CacheService(c.env.KV);
1519
- const event = await db.query.events.findFirst({
1520
- where: eq(events.gformsId, formId),
1521
- columns: { slug: true, maxSlots: true, registeredSlots: true }
1522
- });
1523
- if (!event) {
1524
- console.warn(`[gforms-webhook] Unknown formId: ${formId}`);
1765
+ );
1766
+ var gformsWebhookRoute = new Hono();
1767
+ gformsWebhookRoute.post(
1768
+ "/",
1769
+ describeRoute({
1770
+ tags: ["Internal"],
1771
+ summary: "Google Forms webhook receiver",
1772
+ description: "Receives Google Forms Watch push notifications and increments slot counters.",
1773
+ responses: {
1774
+ 200: { description: "Notification processed" },
1775
+ 400: { description: "Invalid payload" },
1776
+ 403: { description: "Invalid HMAC signature" }
1777
+ }
1778
+ }),
1779
+ internalMiddleware,
1780
+ async (c) => {
1781
+ const rawBody = await c.req.text();
1782
+ const signature = c.req.header("X-Goog-Signature");
1783
+ if (signature) {
1784
+ const isValid = await verifyGoogSignature(
1785
+ rawBody,
1786
+ signature,
1787
+ c.env.GFORMS_WEBHOOK_SECRET
1788
+ );
1789
+ if (!isValid) {
1790
+ return c.json({ error: "Invalid signature" }, 403);
1791
+ }
1792
+ }
1793
+ let payload;
1794
+ try {
1795
+ payload = JSON.parse(rawBody);
1796
+ } catch {
1797
+ return c.json({ error: "Invalid payload" }, 400);
1798
+ }
1799
+ const { formId } = payload;
1800
+ if (!formId) return c.json({ error: "Missing formId" }, 400);
1801
+ const db = createDb(c.env.DB);
1802
+ const cache = new CacheService(c.env.KV);
1803
+ const event = await db.query.events.findFirst({
1804
+ where: eq(events.gformsId, formId),
1805
+ columns: { slug: true, maxSlots: true, registeredSlots: true }
1806
+ });
1807
+ if (!event) {
1808
+ console.warn(`[gforms-webhook] Unknown formId: ${formId}`);
1809
+ return c.json({ ok: true });
1810
+ }
1811
+ const slotsService = new SlotsService(db, cache);
1812
+ const updated = await slotsService.increment(event.slug);
1813
+ console.log(
1814
+ `[gforms-webhook] Incremented "${event.slug}": ${updated?.registered}/${updated?.total}`
1815
+ );
1525
1816
  return c.json({ ok: true });
1526
1817
  }
1527
- const slotsService = new SlotsService(db, cache);
1528
- const updated = await slotsService.increment(event.slug);
1529
- console.log(
1530
- `[gforms-webhook] Incremented "${event.slug}": ${updated?.registered}/${updated?.total}`
1531
- );
1532
- return c.json({ ok: true });
1533
- });
1818
+ );
1534
1819
  async function verifyGoogSignature(body, signature, secret) {
1535
1820
  try {
1536
1821
  const key = await crypto.subtle.importKey(
@@ -1599,10 +1884,20 @@ async function reconcileSlots(env) {
1599
1884
 
1600
1885
  // src/routes/internal/reconcile-slots.ts
1601
1886
  var reconcileSlotsRoute = new Hono();
1602
- reconcileSlotsRoute.post("/", internalMiddleware, async (c) => {
1603
- await reconcileSlots(c.env);
1604
- return c.json({ ok: true });
1605
- });
1887
+ reconcileSlotsRoute.post(
1888
+ "/",
1889
+ describeRoute({
1890
+ tags: ["Internal"],
1891
+ summary: "Reconcile event slot counts",
1892
+ description: "Triggers slot count reconciliation for all events with Google Forms.",
1893
+ responses: { 200: { description: "Reconciliation complete" } }
1894
+ }),
1895
+ internalMiddleware,
1896
+ async (c) => {
1897
+ await reconcileSlots(c.env);
1898
+ return c.json({ ok: true });
1899
+ }
1900
+ );
1606
1901
  async function batchRelease(env) {
1607
1902
  const db = createDb(env.DB);
1608
1903
  const cache = new CacheService(env.KV);
@@ -1630,10 +1925,20 @@ async function batchRelease(env) {
1630
1925
 
1631
1926
  // src/routes/internal/batch-release.ts
1632
1927
  var batchReleaseRoute = new Hono();
1633
- batchReleaseRoute.post("/", internalMiddleware, async (c) => {
1634
- await batchRelease(c.env);
1635
- return c.json({ ok: true });
1636
- });
1928
+ batchReleaseRoute.post(
1929
+ "/",
1930
+ describeRoute({
1931
+ tags: ["Internal"],
1932
+ summary: "Batch release queued events",
1933
+ description: "Triggers batch release of queued events whose releaseAt time has passed.",
1934
+ responses: { 200: { description: "Batch release complete" } }
1935
+ }),
1936
+ internalMiddleware,
1937
+ async (c) => {
1938
+ await batchRelease(c.env);
1939
+ return c.json({ ok: true });
1940
+ }
1941
+ );
1637
1942
  function parseStartTimestamp(dateTime, startTime) {
1638
1943
  if (!dateTime) return null;
1639
1944
  const combined = startTime ? `${dateTime} ${startTime}` : dateTime;
@@ -1705,10 +2010,20 @@ async function reminderEmails(env) {
1705
2010
 
1706
2011
  // src/routes/internal/reminder-emails.ts
1707
2012
  var reminderEmailsRoute = new Hono();
1708
- reminderEmailsRoute.post("/", internalMiddleware, async (c) => {
1709
- await reminderEmails(c.env);
1710
- return c.json({ ok: true });
1711
- });
2013
+ reminderEmailsRoute.post(
2014
+ "/",
2015
+ describeRoute({
2016
+ tags: ["Internal"],
2017
+ summary: "Send reminder emails",
2018
+ description: "Triggers reminder email processing for upcoming events.",
2019
+ responses: { 200: { description: "Reminder emails processed" } }
2020
+ }),
2021
+ internalMiddleware,
2022
+ async (c) => {
2023
+ await reminderEmails(c.env);
2024
+ return c.json({ ok: true });
2025
+ }
2026
+ );
1712
2027
  var RENEWAL_WINDOW = 86400;
1713
2028
  async function renewWatches(env) {
1714
2029
  const db = createDb(env.DB);
@@ -1740,10 +2055,20 @@ async function renewWatches(env) {
1740
2055
 
1741
2056
  // src/routes/internal/renew-watches.ts
1742
2057
  var renewWatchesRoute = new Hono();
1743
- renewWatchesRoute.post("/", internalMiddleware, async (c) => {
1744
- await renewWatches(c.env);
1745
- return c.json({ ok: true });
1746
- });
2058
+ renewWatchesRoute.post(
2059
+ "/",
2060
+ describeRoute({
2061
+ tags: ["Internal"],
2062
+ summary: "Renew Google Forms watches",
2063
+ description: "Triggers renewal of expiring Google Forms Watch subscriptions.",
2064
+ responses: { 200: { description: "Watch renewal complete" } }
2065
+ }),
2066
+ internalMiddleware,
2067
+ async (c) => {
2068
+ await renewWatches(c.env);
2069
+ return c.json({ ok: true });
2070
+ }
2071
+ );
1747
2072
  var ALLOWED_MIME_TYPES = /* @__PURE__ */ new Set([
1748
2073
  "image/jpeg",
1749
2074
  "image/png",
@@ -1753,30 +2078,50 @@ var ALLOWED_MIME_TYPES = /* @__PURE__ */ new Set([
1753
2078
  ]);
1754
2079
  var MAX_FILE_SIZE = 10 * 1024 * 1024;
1755
2080
  var uploadsRoute = new Hono();
1756
- uploadsRoute.get("/images/*", async (c) => {
1757
- const bucket = c.env.FILES;
1758
- if (!bucket) {
1759
- throw serviceUnavailable("File storage (R2) is not configured.");
1760
- }
1761
- const path = c.req.path.split("/uploads/images/")[1];
1762
- if (!path) throw notFound("Image");
1763
- const object = await bucket.get(path);
1764
- if (!object) throw notFound("Image");
1765
- const headers = {
1766
- etag: object.httpEtag,
1767
- // Cache at the edge/browser for 1 month
1768
- "Cache-Control": "public, max-age=2592000, immutable"
1769
- };
1770
- if (object.httpMetadata?.contentType) {
1771
- headers["Content-Type"] = object.httpMetadata.contentType;
1772
- }
1773
- if (object.httpMetadata?.cacheControl) {
1774
- headers["Cache-Control"] = object.httpMetadata.cacheControl;
2081
+ uploadsRoute.get(
2082
+ "/images/*",
2083
+ describeRoute({
2084
+ tags: ["Uploads"],
2085
+ summary: "Serve an image from R2 storage",
2086
+ responses: {
2087
+ 200: { description: "Image file" },
2088
+ 404: { description: "Image not found" }
2089
+ }
2090
+ }),
2091
+ async (c) => {
2092
+ const bucket = c.env.FILES;
2093
+ if (!bucket) {
2094
+ throw serviceUnavailable("File storage (R2) is not configured.");
2095
+ }
2096
+ const path = c.req.path.split("/uploads/images/")[1];
2097
+ if (!path) throw notFound("Image");
2098
+ const object = await bucket.get(path);
2099
+ if (!object) throw notFound("Image");
2100
+ const headers = {
2101
+ etag: object.httpEtag,
2102
+ // Cache at the edge/browser for 1 month
2103
+ "Cache-Control": "public, max-age=2592000, immutable"
2104
+ };
2105
+ if (object.httpMetadata?.contentType) {
2106
+ headers["Content-Type"] = object.httpMetadata.contentType;
2107
+ }
2108
+ if (object.httpMetadata?.cacheControl) {
2109
+ headers["Cache-Control"] = object.httpMetadata.cacheControl;
2110
+ }
2111
+ return c.body(object.body, 200, headers);
1775
2112
  }
1776
- return c.body(object.body, 200, headers);
1777
- });
2113
+ );
1778
2114
  uploadsRoute.post(
1779
2115
  "/images",
2116
+ describeRoute({
2117
+ tags: ["Uploads"],
2118
+ summary: "Upload an image to R2 storage (admin)",
2119
+ responses: {
2120
+ 201: { description: "Image uploaded successfully" },
2121
+ 400: { description: "Invalid file or MIME type" },
2122
+ 503: { description: "R2 storage not configured" }
2123
+ }
2124
+ }),
1780
2125
  authMiddleware,
1781
2126
  adminMiddleware,
1782
2127
  async (c) => {
@@ -1861,16 +2206,33 @@ z.object({
1861
2206
  sortOrder: z.number().int().optional()
1862
2207
  });
1863
2208
  var themesRoute = new Hono();
1864
- themesRoute.get("/", async (c) => {
1865
- const db = createDb(c.env.DB);
1866
- const data = await db.select().from(themes).orderBy(asc(themes.sortOrder), asc(themes.createdAt));
1867
- return c.json({ data });
1868
- });
2209
+ themesRoute.get(
2210
+ "/",
2211
+ describeRoute({
2212
+ tags: ["Themes"],
2213
+ summary: "List all themes",
2214
+ responses: { 200: { description: "List of themes" } }
2215
+ }),
2216
+ async (c) => {
2217
+ const db = createDb(c.env.DB);
2218
+ const data = await db.select().from(themes).orderBy(asc(themes.sortOrder), asc(themes.createdAt));
2219
+ return c.json({ data });
2220
+ }
2221
+ );
1869
2222
  themesRoute.post(
1870
2223
  "/",
2224
+ describeRoute({
2225
+ tags: ["Themes"],
2226
+ summary: "Create a new theme (admin)",
2227
+ responses: {
2228
+ 201: { description: "Theme created" },
2229
+ 409: { description: "Theme already exists" },
2230
+ 422: { description: "Validation error" }
2231
+ }
2232
+ }),
1871
2233
  authMiddleware,
1872
2234
  adminMiddleware,
1873
- zValidator("json", createThemeSchema),
2235
+ validator("json", createThemeSchema),
1874
2236
  async (c) => {
1875
2237
  const body = c.req.valid("json");
1876
2238
  const db = createDb(c.env.DB);
@@ -1888,6 +2250,15 @@ themesRoute.post(
1888
2250
  );
1889
2251
  themesRoute.patch(
1890
2252
  "/:id",
2253
+ describeRoute({
2254
+ tags: ["Themes"],
2255
+ summary: "Update a theme (admin)",
2256
+ responses: {
2257
+ 200: { description: "Theme updated" },
2258
+ 404: { description: "Theme not found" },
2259
+ 409: { description: "Theme already exists" }
2260
+ }
2261
+ }),
1891
2262
  authMiddleware,
1892
2263
  adminMiddleware,
1893
2264
  async (c) => {
@@ -1910,13 +2281,26 @@ themesRoute.patch(
1910
2281
  }
1911
2282
  }
1912
2283
  );
1913
- themesRoute.delete("/:id", authMiddleware, adminMiddleware, async (c) => {
1914
- const { id } = c.req.param();
1915
- const db = createDb(c.env.DB);
1916
- const [deleted] = await db.delete(themes).where(eq(themes.id, id)).returning();
1917
- if (!deleted) throw notFound("Theme");
1918
- return c.body(null, 204);
1919
- });
2284
+ themesRoute.delete(
2285
+ "/:id",
2286
+ describeRoute({
2287
+ tags: ["Themes"],
2288
+ summary: "Delete a theme (admin)",
2289
+ responses: {
2290
+ 204: { description: "Theme deleted" },
2291
+ 404: { description: "Theme not found" }
2292
+ }
2293
+ }),
2294
+ authMiddleware,
2295
+ adminMiddleware,
2296
+ async (c) => {
2297
+ const { id } = c.req.param();
2298
+ const db = createDb(c.env.DB);
2299
+ const [deleted] = await db.delete(themes).where(eq(themes.id, id)).returning();
2300
+ if (!deleted) throw notFound("Theme");
2301
+ return c.body(null, 204);
2302
+ }
2303
+ );
1920
2304
  var createOrganizationSchema = z.object({
1921
2305
  name: z.string().min(1),
1922
2306
  acronym: z.string().min(1),
@@ -1924,16 +2308,33 @@ var createOrganizationSchema = z.object({
1924
2308
  link: z.string().url().nullable().optional()
1925
2309
  });
1926
2310
  var organizationsRoute = new Hono();
1927
- organizationsRoute.get("/", async (c) => {
1928
- const db = createDb(c.env.DB);
1929
- const data = await db.select().from(organizations);
1930
- return c.json({ data });
1931
- });
2311
+ organizationsRoute.get(
2312
+ "/",
2313
+ describeRoute({
2314
+ tags: ["Organizations"],
2315
+ summary: "List all organizations",
2316
+ responses: { 200: { description: "List of organizations" } }
2317
+ }),
2318
+ async (c) => {
2319
+ const db = createDb(c.env.DB);
2320
+ const data = await db.select().from(organizations);
2321
+ return c.json({ data });
2322
+ }
2323
+ );
1932
2324
  organizationsRoute.post(
1933
2325
  "/",
2326
+ describeRoute({
2327
+ tags: ["Organizations"],
2328
+ summary: "Create a new organization (admin)",
2329
+ responses: {
2330
+ 201: { description: "Organization created" },
2331
+ 409: { description: "Organization already exists" },
2332
+ 422: { description: "Validation error" }
2333
+ }
2334
+ }),
1934
2335
  authMiddleware,
1935
2336
  adminMiddleware,
1936
- zValidator("json", createOrganizationSchema),
2337
+ validator("json", createOrganizationSchema),
1937
2338
  async (c) => {
1938
2339
  const body = c.req.valid("json");
1939
2340
  const db = createDb(c.env.DB);
@@ -1950,6 +2351,15 @@ organizationsRoute.post(
1950
2351
  );
1951
2352
  organizationsRoute.patch(
1952
2353
  "/:id",
2354
+ describeRoute({
2355
+ tags: ["Organizations"],
2356
+ summary: "Update an organization (admin)",
2357
+ responses: {
2358
+ 200: { description: "Organization updated" },
2359
+ 404: { description: "Organization not found" },
2360
+ 409: { description: "Organization already exists" }
2361
+ }
2362
+ }),
1953
2363
  authMiddleware,
1954
2364
  adminMiddleware,
1955
2365
  async (c) => {
@@ -1968,13 +2378,26 @@ organizationsRoute.patch(
1968
2378
  }
1969
2379
  }
1970
2380
  );
1971
- organizationsRoute.delete("/:id", authMiddleware, adminMiddleware, async (c) => {
1972
- const { id } = c.req.param();
1973
- const db = createDb(c.env.DB);
1974
- const [deleted] = await db.delete(organizations).where(eq(organizations.id, id)).returning();
1975
- if (!deleted) throw notFound("Organization");
1976
- return c.body(null, 204);
1977
- });
2381
+ organizationsRoute.delete(
2382
+ "/:id",
2383
+ describeRoute({
2384
+ tags: ["Organizations"],
2385
+ summary: "Delete an organization (admin)",
2386
+ responses: {
2387
+ 204: { description: "Organization deleted" },
2388
+ 404: { description: "Organization not found" }
2389
+ }
2390
+ }),
2391
+ authMiddleware,
2392
+ adminMiddleware,
2393
+ async (c) => {
2394
+ const { id } = c.req.param();
2395
+ const db = createDb(c.env.DB);
2396
+ const [deleted] = await db.delete(organizations).where(eq(organizations.id, id)).returning();
2397
+ if (!deleted) throw notFound("Organization");
2398
+ return c.body(null, 204);
2399
+ }
2400
+ );
1978
2401
 
1979
2402
  // src/app.ts
1980
2403
  function createApp(options = {}) {
@@ -2032,6 +2455,27 @@ function createApp(options = {}) {
2032
2455
  app.route("/internal/batch-release", batchReleaseRoute);
2033
2456
  app.route("/internal/reminder-emails", reminderEmailsRoute);
2034
2457
  app.route("/internal/renew-watches", renewWatchesRoute);
2458
+ app.get(
2459
+ "/api/openapi.json",
2460
+ authMiddleware,
2461
+ adminMiddleware,
2462
+ openAPIRouteHandler(app, {
2463
+ documentation: {
2464
+ info: {
2465
+ title: "Leapify API",
2466
+ version: "0.260605.1" ,
2467
+ description: "DLSU CSO LEAP backend API"
2468
+ },
2469
+ openapi: "3.1.0"
2470
+ }
2471
+ })
2472
+ );
2473
+ app.get(
2474
+ "/api/docs",
2475
+ authMiddleware,
2476
+ adminMiddleware,
2477
+ swaggerUI({ url: "/api/openapi.json" })
2478
+ );
2035
2479
  app.onError(errorHandler);
2036
2480
  app.notFound(
2037
2481
  (c) => c.json({ error: { code: "NOT_FOUND", message: "Route not found" } }, 404)
@@ -2652,6 +3096,8 @@ var API_PREFIXES = [
2652
3096
  "/api/themes",
2653
3097
  "/api/config",
2654
3098
  "/api/uploads",
3099
+ "/api/docs",
3100
+ "/api/openapi.json",
2655
3101
  "/health",
2656
3102
  "/internal/",
2657
3103
  "/.well-known/"