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