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