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