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