@access-dlsu/leapify 0.260601.1 → 0.260601.2
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/client/index.cjs +7 -0
- package/dist/client/index.cjs.map +1 -1
- package/dist/client/index.d.ts +7 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +7 -0
- package/dist/client/index.js.map +1 -1
- package/dist/cron/reconcile-slots.d.ts.map +1 -1
- package/dist/index.cjs +221 -165
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +223 -167
- package/dist/index.js.map +1 -1
- package/dist/routes/internal/batch-release.d.ts +4 -0
- package/dist/routes/internal/batch-release.d.ts.map +1 -0
- package/dist/routes/internal/reconcile-slots.d.ts +4 -0
- package/dist/routes/internal/reconcile-slots.d.ts.map +1 -0
- package/dist/routes/internal/reminder-emails.d.ts +4 -0
- package/dist/routes/internal/reminder-emails.d.ts.map +1 -0
- package/dist/routes/internal/renew-watches.d.ts +4 -0
- package/dist/routes/internal/renew-watches.d.ts.map +1 -0
- package/dist/services/gforms.d.ts.map +1 -1
- package/dist/worker.js +220 -164
- package/dist/worker.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"batch-release.d.ts","sourceRoot":"","sources":["../../../src/routes/internal/batch-release.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAI9C,eAAO,MAAM,iBAAiB,yDAAyB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reconcile-slots.d.ts","sourceRoot":"","sources":["../../../src/routes/internal/reconcile-slots.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAI9C,eAAO,MAAM,mBAAmB,yDAAyB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reminder-emails.d.ts","sourceRoot":"","sources":["../../../src/routes/internal/reminder-emails.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAI9C,eAAO,MAAM,mBAAmB,yDAAyB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"renew-watches.d.ts","sourceRoot":"","sources":["../../../src/routes/internal/renew-watches.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAI9C,eAAO,MAAM,iBAAiB,yDAAyB,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gforms.d.ts","sourceRoot":"","sources":["../../src/services/gforms.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAiBH,UAAU,YAAY;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAOD,qBAAa,aAAa;IACxB,OAAO,CAAC,WAAW,CAAuB;IAC1C,OAAO,CAAC,cAAc,CAAK;IAC3B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA4B;gBAE5C,kBAAkB,EAAE,MAAM;YAQxB,cAAc;YAWd,wBAAwB;YA6BxB,SAAS;IA6CvB;;;OAGG;IACG,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAIvD;;;OAGG;IACG,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"gforms.d.ts","sourceRoot":"","sources":["../../src/services/gforms.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAiBH,UAAU,YAAY;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAOD,qBAAa,aAAa;IACxB,OAAO,CAAC,WAAW,CAAuB;IAC1C,OAAO,CAAC,cAAc,CAAK;IAC3B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA4B;gBAE5C,kBAAkB,EAAE,MAAM;YAQxB,cAAc;YAWd,wBAAwB;YA6BxB,SAAS;IA6CvB;;;OAGG;IACG,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAIvD;;;OAGG;IACG,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;IA6B9D;;;OAGG;IACG,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAO5D;;;OAGG;IACG,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAO5D;;;;;;;OAOG;IACG,WAAW,CACf,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;IAiCnD;;OAEG;IACG,UAAU,CACd,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,GACd,OAAO,CAAC;QAAE,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;CAuBnC"}
|
package/dist/worker.js
CHANGED
|
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
|
|
2
2
|
import { cors } from 'hono/cors';
|
|
3
3
|
import { drizzle } from 'drizzle-orm/d1';
|
|
4
4
|
import { sqliteTable, integer, text, index, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
|
5
|
-
import { sql, relations, eq, and, count, lte } from 'drizzle-orm';
|
|
5
|
+
import { sql, relations, eq, and, count, isNotNull, lte } from 'drizzle-orm';
|
|
6
6
|
import { createMiddleware } from 'hono/factory';
|
|
7
7
|
import { betterAuth } from 'better-auth';
|
|
8
8
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
|
@@ -1108,7 +1108,10 @@ var GFormsService = class {
|
|
|
1108
1108
|
const response = await fetch(url.toString(), {
|
|
1109
1109
|
headers: { Authorization: `Bearer ${token}` }
|
|
1110
1110
|
});
|
|
1111
|
-
if (!response.ok)
|
|
1111
|
+
if (!response.ok) {
|
|
1112
|
+
const err = await response.text();
|
|
1113
|
+
throw new Error(`Forms API error: ${response.status} ${err}`);
|
|
1114
|
+
}
|
|
1112
1115
|
const data = await response.json();
|
|
1113
1116
|
allResponses.push(...data.responses ?? []);
|
|
1114
1117
|
pageToken = data.nextPageToken;
|
|
@@ -1382,6 +1385,27 @@ classesRoute.get("/:slug/slots", eventsSlotsRateLimit, async (c) => {
|
|
|
1382
1385
|
c.header("Cache-Control", "public, max-age=5, stale-while-revalidate=5");
|
|
1383
1386
|
return c.json({ data: info });
|
|
1384
1387
|
});
|
|
1388
|
+
classesRoute.post("/:slug/reconcile", authMiddleware, adminMiddleware, async (c) => {
|
|
1389
|
+
const { slug } = c.req.param();
|
|
1390
|
+
const db = createDb(c.env.DB);
|
|
1391
|
+
const cache = new CacheService(c.env.KV);
|
|
1392
|
+
const gforms = new GFormsService(c.env.GFORMS_SERVICE_ACCOUNT_JSON);
|
|
1393
|
+
const slots = new SlotsService(db, cache);
|
|
1394
|
+
const event = await db.query.events.findFirst({
|
|
1395
|
+
where: eq(events.slug, slug),
|
|
1396
|
+
columns: { gformsId: true }
|
|
1397
|
+
});
|
|
1398
|
+
if (!event) throw notFound("Event");
|
|
1399
|
+
if (!event.gformsId) return c.json({ error: "No gformsId set for this event" }, 400);
|
|
1400
|
+
try {
|
|
1401
|
+
const googleCount = await gforms.getExactResponseCount(event.gformsId);
|
|
1402
|
+
await slots.correctCount(slug, googleCount);
|
|
1403
|
+
return c.json({ data: { registeredSlots: googleCount } });
|
|
1404
|
+
} catch (err) {
|
|
1405
|
+
const message = err?.message ?? "Failed to fetch from Google Forms API";
|
|
1406
|
+
return c.json({ error: { code: "GFORMS_API_ERROR", message } }, 502);
|
|
1407
|
+
}
|
|
1408
|
+
});
|
|
1385
1409
|
classesRoute.post(
|
|
1386
1410
|
"/",
|
|
1387
1411
|
authMiddleware,
|
|
@@ -1689,6 +1713,196 @@ async function verifyGoogSignature(body, signature, secret) {
|
|
|
1689
1713
|
return false;
|
|
1690
1714
|
}
|
|
1691
1715
|
}
|
|
1716
|
+
var LOCK_KEY = "cron:reconcile-slots:lock";
|
|
1717
|
+
var LOCK_TTL = 300;
|
|
1718
|
+
async function reconcileSlots(env) {
|
|
1719
|
+
const db = createDb(env.DB);
|
|
1720
|
+
const cache = new CacheService(env.KV);
|
|
1721
|
+
const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
|
|
1722
|
+
const slots = new SlotsService(db, cache);
|
|
1723
|
+
const lock = await cache.get(LOCK_KEY);
|
|
1724
|
+
if (lock) {
|
|
1725
|
+
console.log("[reconcile-slots] Lock held, skipping.");
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
await cache.set(LOCK_KEY, "1", LOCK_TTL);
|
|
1729
|
+
try {
|
|
1730
|
+
const publishedEvents = await db.query.events.findMany({
|
|
1731
|
+
where: isNotNull(events.gformsId),
|
|
1732
|
+
columns: { id: true, slug: true, gformsId: true, registeredSlots: true }
|
|
1733
|
+
});
|
|
1734
|
+
const eventsWithForms = publishedEvents.filter((e) => e.gformsId);
|
|
1735
|
+
let corrected = 0;
|
|
1736
|
+
for (const event of eventsWithForms) {
|
|
1737
|
+
try {
|
|
1738
|
+
const googleCount = await gforms.getExactResponseCount(event.gformsId);
|
|
1739
|
+
const localCount = event.registeredSlots;
|
|
1740
|
+
if (googleCount !== localCount) {
|
|
1741
|
+
console.warn(
|
|
1742
|
+
`[reconcile-slots] Drift on "${event.slug}": local=${localCount}, google=${googleCount}`
|
|
1743
|
+
);
|
|
1744
|
+
await slots.correctCount(event.slug, googleCount);
|
|
1745
|
+
corrected++;
|
|
1746
|
+
}
|
|
1747
|
+
} catch (err) {
|
|
1748
|
+
console.error(`[reconcile-slots] Error checking "${event.slug}":`, err);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
console.log(
|
|
1752
|
+
`[reconcile-slots] Checked ${eventsWithForms.length} events, corrected ${corrected}.`
|
|
1753
|
+
);
|
|
1754
|
+
} finally {
|
|
1755
|
+
await cache.del(LOCK_KEY);
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// src/routes/internal/reconcile-slots.ts
|
|
1760
|
+
var reconcileSlotsRoute = new Hono();
|
|
1761
|
+
reconcileSlotsRoute.post("/", internalMiddleware, async (c) => {
|
|
1762
|
+
await reconcileSlots(c.env);
|
|
1763
|
+
return c.json({ ok: true });
|
|
1764
|
+
});
|
|
1765
|
+
async function batchRelease(env) {
|
|
1766
|
+
const db = createDb(env.DB);
|
|
1767
|
+
const cache = new CacheService(env.KV);
|
|
1768
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1769
|
+
const toPublish = await db.query.events.findMany({
|
|
1770
|
+
where: and(eq(events.status, "queued"), lte(events.releaseAt, now)),
|
|
1771
|
+
columns: { id: true, slug: true }
|
|
1772
|
+
});
|
|
1773
|
+
if (toPublish.length === 0) return;
|
|
1774
|
+
const ids = toPublish.map((e) => e.id);
|
|
1775
|
+
await db.update(events).set({ status: "published", publishedAt: sql`(unixepoch())` }).where(
|
|
1776
|
+
// Drizzle doesn't have inArray for D1; use raw SQL for batch
|
|
1777
|
+
sql`${events.id} IN (${sql.join(
|
|
1778
|
+
ids.map((id) => sql`${id}`),
|
|
1779
|
+
sql`, `
|
|
1780
|
+
)})`
|
|
1781
|
+
);
|
|
1782
|
+
await cache.del("events:list");
|
|
1783
|
+
await cache.del("events:etag");
|
|
1784
|
+
console.log(
|
|
1785
|
+
`[batch-release] Published ${toPublish.length} events:`,
|
|
1786
|
+
toPublish.map((e) => e.slug).join(", ")
|
|
1787
|
+
);
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
// src/routes/internal/batch-release.ts
|
|
1791
|
+
var batchReleaseRoute = new Hono();
|
|
1792
|
+
batchReleaseRoute.post("/", internalMiddleware, async (c) => {
|
|
1793
|
+
await batchRelease(c.env);
|
|
1794
|
+
return c.json({ ok: true });
|
|
1795
|
+
});
|
|
1796
|
+
function parseStartTimestamp(dateTime, startTime) {
|
|
1797
|
+
if (!dateTime) return null;
|
|
1798
|
+
const combined = startTime ? `${dateTime} ${startTime}` : dateTime;
|
|
1799
|
+
const ms = Date.parse(combined);
|
|
1800
|
+
return Number.isNaN(ms) ? null : Math.floor(ms / 1e3);
|
|
1801
|
+
}
|
|
1802
|
+
async function reminderEmails(env) {
|
|
1803
|
+
if (!env.EMAIL_QUEUE) {
|
|
1804
|
+
console.warn(
|
|
1805
|
+
"[reminder-emails] EMAIL_QUEUE binding not configured, skipping."
|
|
1806
|
+
);
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
const hasSes = !!(env.SES_REGION && env.SES_ACCESS_KEY_ID && env.SES_SECRET_ACCESS_KEY);
|
|
1810
|
+
const hasResend = !!env.RESEND_API_KEY;
|
|
1811
|
+
if (!hasSes && !hasResend) {
|
|
1812
|
+
console.warn(
|
|
1813
|
+
"[reminder-emails] No email providers configured. Skipping reminders."
|
|
1814
|
+
);
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
const db = createDb(env.DB);
|
|
1818
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1819
|
+
const candidates24h = await db.query.events.findMany({
|
|
1820
|
+
where: and(
|
|
1821
|
+
eq(events.status, "published"),
|
|
1822
|
+
eq(events.reminder24hSent, false)
|
|
1823
|
+
),
|
|
1824
|
+
columns: {
|
|
1825
|
+
id: true,
|
|
1826
|
+
dateTime: true,
|
|
1827
|
+
startTime: true
|
|
1828
|
+
}
|
|
1829
|
+
});
|
|
1830
|
+
for (const event of candidates24h) {
|
|
1831
|
+
const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
|
|
1832
|
+
if (!startsAt) continue;
|
|
1833
|
+
const hoursUntil = (startsAt - now) / 3600;
|
|
1834
|
+
if (hoursUntil <= 25 && hoursUntil >= 23) {
|
|
1835
|
+
await env.EMAIL_QUEUE.send({
|
|
1836
|
+
type: "send_reminder_email",
|
|
1837
|
+
payload: { eventId: event.id, hoursBeforeEvent: 24 }
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
const candidates1h = await db.query.events.findMany({
|
|
1842
|
+
where: and(
|
|
1843
|
+
eq(events.status, "published"),
|
|
1844
|
+
eq(events.reminder1hSent, false)
|
|
1845
|
+
),
|
|
1846
|
+
columns: {
|
|
1847
|
+
id: true,
|
|
1848
|
+
dateTime: true,
|
|
1849
|
+
startTime: true
|
|
1850
|
+
}
|
|
1851
|
+
});
|
|
1852
|
+
for (const event of candidates1h) {
|
|
1853
|
+
const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
|
|
1854
|
+
if (!startsAt) continue;
|
|
1855
|
+
const hoursUntil = (startsAt - now) / 3600;
|
|
1856
|
+
if (hoursUntil <= 1.5 && hoursUntil >= 0) {
|
|
1857
|
+
await env.EMAIL_QUEUE.send({
|
|
1858
|
+
type: "send_reminder_email",
|
|
1859
|
+
payload: { eventId: event.id, hoursBeforeEvent: 1 }
|
|
1860
|
+
});
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// src/routes/internal/reminder-emails.ts
|
|
1866
|
+
var reminderEmailsRoute = new Hono();
|
|
1867
|
+
reminderEmailsRoute.post("/", internalMiddleware, async (c) => {
|
|
1868
|
+
await reminderEmails(c.env);
|
|
1869
|
+
return c.json({ ok: true });
|
|
1870
|
+
});
|
|
1871
|
+
var RENEWAL_WINDOW = 86400;
|
|
1872
|
+
async function renewWatches(env) {
|
|
1873
|
+
const db = createDb(env.DB);
|
|
1874
|
+
const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
|
|
1875
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1876
|
+
const threshold = now + RENEWAL_WINDOW;
|
|
1877
|
+
const expiring = await db.query.events.findMany({
|
|
1878
|
+
where: and(
|
|
1879
|
+
eq(events.status, "published"),
|
|
1880
|
+
lte(events.watchExpiresAt, threshold)
|
|
1881
|
+
),
|
|
1882
|
+
columns: { id: true, slug: true, gformsId: true, watchId: true, watchExpiresAt: true }
|
|
1883
|
+
});
|
|
1884
|
+
const watchEvents = expiring.filter((e) => e.gformsId && e.watchId);
|
|
1885
|
+
let renewed = 0;
|
|
1886
|
+
for (const event of watchEvents) {
|
|
1887
|
+
try {
|
|
1888
|
+
const result = await gforms.renewWatch(event.gformsId, event.watchId);
|
|
1889
|
+
const newExpiry = Math.floor(new Date(result.expireTime).getTime() / 1e3);
|
|
1890
|
+
await db.update(events).set({ watchExpiresAt: newExpiry }).where(eq(events.id, event.id));
|
|
1891
|
+
renewed++;
|
|
1892
|
+
console.log(`[renew-watches] Renewed Watch for "${event.slug}", expires ${result.expireTime}`);
|
|
1893
|
+
} catch (err) {
|
|
1894
|
+
console.error(`[renew-watches] Failed to renew Watch for "${event.slug}":`, err);
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
console.log(`[renew-watches] Renewed ${renewed}/${watchEvents.length} watches.`);
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
// src/routes/internal/renew-watches.ts
|
|
1901
|
+
var renewWatchesRoute = new Hono();
|
|
1902
|
+
renewWatchesRoute.post("/", internalMiddleware, async (c) => {
|
|
1903
|
+
await renewWatches(c.env);
|
|
1904
|
+
return c.json({ ok: true });
|
|
1905
|
+
});
|
|
1692
1906
|
var ALLOWED_MIME_TYPES = /* @__PURE__ */ new Set([
|
|
1693
1907
|
"image/jpeg",
|
|
1694
1908
|
"image/png",
|
|
@@ -1955,6 +2169,10 @@ function createApp(options = {}) {
|
|
|
1955
2169
|
app2.route("/api/faqs", faqsRoute);
|
|
1956
2170
|
app2.route("/api/uploads", uploadsRoute);
|
|
1957
2171
|
app2.route("/internal/gforms-webhook", gformsWebhookRoute);
|
|
2172
|
+
app2.route("/internal/reconcile-slots", reconcileSlotsRoute);
|
|
2173
|
+
app2.route("/internal/batch-release", batchReleaseRoute);
|
|
2174
|
+
app2.route("/internal/reminder-emails", reminderEmailsRoute);
|
|
2175
|
+
app2.route("/internal/renew-watches", renewWatchesRoute);
|
|
1958
2176
|
app2.onError(errorHandler);
|
|
1959
2177
|
app2.notFound(
|
|
1960
2178
|
(c) => c.json({ error: { code: "NOT_FOUND", message: "Route not found" } }, 404)
|
|
@@ -2385,168 +2603,6 @@ async function processJob(job, services) {
|
|
|
2385
2603
|
}
|
|
2386
2604
|
}
|
|
2387
2605
|
}
|
|
2388
|
-
async function batchRelease(env) {
|
|
2389
|
-
const db = createDb(env.DB);
|
|
2390
|
-
const cache = new CacheService(env.KV);
|
|
2391
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
2392
|
-
const toPublish = await db.query.events.findMany({
|
|
2393
|
-
where: and(eq(events.status, "queued"), lte(events.releaseAt, now)),
|
|
2394
|
-
columns: { id: true, slug: true }
|
|
2395
|
-
});
|
|
2396
|
-
if (toPublish.length === 0) return;
|
|
2397
|
-
const ids = toPublish.map((e) => e.id);
|
|
2398
|
-
await db.update(events).set({ status: "published", publishedAt: sql`(unixepoch())` }).where(
|
|
2399
|
-
// Drizzle doesn't have inArray for D1; use raw SQL for batch
|
|
2400
|
-
sql`${events.id} IN (${sql.join(
|
|
2401
|
-
ids.map((id) => sql`${id}`),
|
|
2402
|
-
sql`, `
|
|
2403
|
-
)})`
|
|
2404
|
-
);
|
|
2405
|
-
await cache.del("events:list");
|
|
2406
|
-
await cache.del("events:etag");
|
|
2407
|
-
console.log(
|
|
2408
|
-
`[batch-release] Published ${toPublish.length} events:`,
|
|
2409
|
-
toPublish.map((e) => e.slug).join(", ")
|
|
2410
|
-
);
|
|
2411
|
-
}
|
|
2412
|
-
var LOCK_KEY = "cron:reconcile-slots:lock";
|
|
2413
|
-
var LOCK_TTL = 300;
|
|
2414
|
-
async function reconcileSlots(env) {
|
|
2415
|
-
const db = createDb(env.DB);
|
|
2416
|
-
const cache = new CacheService(env.KV);
|
|
2417
|
-
const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
|
|
2418
|
-
const slots = new SlotsService(db, cache);
|
|
2419
|
-
const lock = await cache.get(LOCK_KEY);
|
|
2420
|
-
if (lock) {
|
|
2421
|
-
console.log("[reconcile-slots] Lock held, skipping.");
|
|
2422
|
-
return;
|
|
2423
|
-
}
|
|
2424
|
-
await cache.set(LOCK_KEY, "1", LOCK_TTL);
|
|
2425
|
-
try {
|
|
2426
|
-
const publishedEvents = await db.query.events.findMany({
|
|
2427
|
-
where: eq(events.status, "published"),
|
|
2428
|
-
columns: { id: true, slug: true, gformsId: true, registeredSlots: true }
|
|
2429
|
-
});
|
|
2430
|
-
const eventsWithForms = publishedEvents.filter((e) => e.gformsId);
|
|
2431
|
-
let corrected = 0;
|
|
2432
|
-
for (const event of eventsWithForms) {
|
|
2433
|
-
try {
|
|
2434
|
-
const googleCount = await gforms.getExactResponseCount(event.gformsId);
|
|
2435
|
-
const localCount = event.registeredSlots;
|
|
2436
|
-
if (googleCount !== localCount) {
|
|
2437
|
-
console.warn(
|
|
2438
|
-
`[reconcile-slots] Drift on "${event.slug}": local=${localCount}, google=${googleCount}`
|
|
2439
|
-
);
|
|
2440
|
-
await slots.correctCount(event.slug, googleCount);
|
|
2441
|
-
corrected++;
|
|
2442
|
-
}
|
|
2443
|
-
} catch (err) {
|
|
2444
|
-
console.error(`[reconcile-slots] Error checking "${event.slug}":`, err);
|
|
2445
|
-
}
|
|
2446
|
-
}
|
|
2447
|
-
console.log(
|
|
2448
|
-
`[reconcile-slots] Checked ${eventsWithForms.length} events, corrected ${corrected}.`
|
|
2449
|
-
);
|
|
2450
|
-
} finally {
|
|
2451
|
-
await cache.del(LOCK_KEY);
|
|
2452
|
-
}
|
|
2453
|
-
}
|
|
2454
|
-
function parseStartTimestamp(dateTime, startTime) {
|
|
2455
|
-
if (!dateTime) return null;
|
|
2456
|
-
const combined = startTime ? `${dateTime} ${startTime}` : dateTime;
|
|
2457
|
-
const ms = Date.parse(combined);
|
|
2458
|
-
return Number.isNaN(ms) ? null : Math.floor(ms / 1e3);
|
|
2459
|
-
}
|
|
2460
|
-
async function reminderEmails(env) {
|
|
2461
|
-
if (!env.EMAIL_QUEUE) {
|
|
2462
|
-
console.warn(
|
|
2463
|
-
"[reminder-emails] EMAIL_QUEUE binding not configured, skipping."
|
|
2464
|
-
);
|
|
2465
|
-
return;
|
|
2466
|
-
}
|
|
2467
|
-
const hasSes = !!(env.SES_REGION && env.SES_ACCESS_KEY_ID && env.SES_SECRET_ACCESS_KEY);
|
|
2468
|
-
const hasResend = !!env.RESEND_API_KEY;
|
|
2469
|
-
if (!hasSes && !hasResend) {
|
|
2470
|
-
console.warn(
|
|
2471
|
-
"[reminder-emails] No email providers configured. Skipping reminders."
|
|
2472
|
-
);
|
|
2473
|
-
return;
|
|
2474
|
-
}
|
|
2475
|
-
const db = createDb(env.DB);
|
|
2476
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
2477
|
-
const candidates24h = await db.query.events.findMany({
|
|
2478
|
-
where: and(
|
|
2479
|
-
eq(events.status, "published"),
|
|
2480
|
-
eq(events.reminder24hSent, false)
|
|
2481
|
-
),
|
|
2482
|
-
columns: {
|
|
2483
|
-
id: true,
|
|
2484
|
-
dateTime: true,
|
|
2485
|
-
startTime: true
|
|
2486
|
-
}
|
|
2487
|
-
});
|
|
2488
|
-
for (const event of candidates24h) {
|
|
2489
|
-
const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
|
|
2490
|
-
if (!startsAt) continue;
|
|
2491
|
-
const hoursUntil = (startsAt - now) / 3600;
|
|
2492
|
-
if (hoursUntil <= 25 && hoursUntil >= 23) {
|
|
2493
|
-
await env.EMAIL_QUEUE.send({
|
|
2494
|
-
type: "send_reminder_email",
|
|
2495
|
-
payload: { eventId: event.id, hoursBeforeEvent: 24 }
|
|
2496
|
-
});
|
|
2497
|
-
}
|
|
2498
|
-
}
|
|
2499
|
-
const candidates1h = await db.query.events.findMany({
|
|
2500
|
-
where: and(
|
|
2501
|
-
eq(events.status, "published"),
|
|
2502
|
-
eq(events.reminder1hSent, false)
|
|
2503
|
-
),
|
|
2504
|
-
columns: {
|
|
2505
|
-
id: true,
|
|
2506
|
-
dateTime: true,
|
|
2507
|
-
startTime: true
|
|
2508
|
-
}
|
|
2509
|
-
});
|
|
2510
|
-
for (const event of candidates1h) {
|
|
2511
|
-
const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
|
|
2512
|
-
if (!startsAt) continue;
|
|
2513
|
-
const hoursUntil = (startsAt - now) / 3600;
|
|
2514
|
-
if (hoursUntil <= 1.5 && hoursUntil >= 0) {
|
|
2515
|
-
await env.EMAIL_QUEUE.send({
|
|
2516
|
-
type: "send_reminder_email",
|
|
2517
|
-
payload: { eventId: event.id, hoursBeforeEvent: 1 }
|
|
2518
|
-
});
|
|
2519
|
-
}
|
|
2520
|
-
}
|
|
2521
|
-
}
|
|
2522
|
-
var RENEWAL_WINDOW = 86400;
|
|
2523
|
-
async function renewWatches(env) {
|
|
2524
|
-
const db = createDb(env.DB);
|
|
2525
|
-
const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
|
|
2526
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
2527
|
-
const threshold = now + RENEWAL_WINDOW;
|
|
2528
|
-
const expiring = await db.query.events.findMany({
|
|
2529
|
-
where: and(
|
|
2530
|
-
eq(events.status, "published"),
|
|
2531
|
-
lte(events.watchExpiresAt, threshold)
|
|
2532
|
-
),
|
|
2533
|
-
columns: { id: true, slug: true, gformsId: true, watchId: true, watchExpiresAt: true }
|
|
2534
|
-
});
|
|
2535
|
-
const watchEvents = expiring.filter((e) => e.gformsId && e.watchId);
|
|
2536
|
-
let renewed = 0;
|
|
2537
|
-
for (const event of watchEvents) {
|
|
2538
|
-
try {
|
|
2539
|
-
const result = await gforms.renewWatch(event.gformsId, event.watchId);
|
|
2540
|
-
const newExpiry = Math.floor(new Date(result.expireTime).getTime() / 1e3);
|
|
2541
|
-
await db.update(events).set({ watchExpiresAt: newExpiry }).where(eq(events.id, event.id));
|
|
2542
|
-
renewed++;
|
|
2543
|
-
console.log(`[renew-watches] Renewed Watch for "${event.slug}", expires ${result.expireTime}`);
|
|
2544
|
-
} catch (err) {
|
|
2545
|
-
console.error(`[renew-watches] Failed to renew Watch for "${event.slug}":`, err);
|
|
2546
|
-
}
|
|
2547
|
-
}
|
|
2548
|
-
console.log(`[renew-watches] Renewed ${renewed}/${watchEvents.length} watches.`);
|
|
2549
|
-
}
|
|
2550
2606
|
|
|
2551
2607
|
// src/db/migrate.ts
|
|
2552
2608
|
var PATCH_STATEMENTS = [
|