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