diff --git a/Dockerfile b/Dockerfile
index 3c9916e..74e9909 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -30,4 +30,6 @@ COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
-CMD sh -c "nginx && cd /app/server && node dist/server.js"
\ No newline at end of file
+WORKDIR /app/server
+
+CMD ["sh", "start.sh"]
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 9b46d8b..1052e9a 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -16,14 +16,6 @@ import mascotSit from "./assets/icons/umsa_sit.png";
import mascotCap from "./assets/icons/umsa_cool.png";
import heroPhoto from "./assets/humans.png";
-const DEFAULTS = {
- heading: "WELCOME TO UMSA",
- subtitle: "The union of Malaysian students in Auckland.",
- aboutHeading: "WHAT IS UMSA?",
- aboutBody: "",
- introVideoUrl: "",
-};
-
const HERO_FOOT =
"Non-profit Malaysian student-run club affiliated with the University of Auckland, New Zealand.";
@@ -113,14 +105,46 @@ function Hero({ heading, subtitle }: { heading: string; subtitle: string }) {
}
export default function App() {
- const { data } = useHomeContent();
- const { data: events } = useEvents();
- const content = { ...DEFAULTS, ...(data ?? {}) };
+ const { data: content, isPending, isFetching, refetch } = useHomeContent();
+ const {
+ data: events,
+ isPending: eventsPending,
+ isError: eventsError,
+ refetch: refetchEvents,
+ isFetching: eventsFetching,
+ } = useEvents();
useEffect(() => {
document.title = "UMSA — Home";
}, []);
+ if (isPending) {
+ return (
+
+ );
+ }
+
+ if (!content) {
+ return (
+
+ We couldn’t load the homepage. Please try again.
+
+
+ );
+ }
+
const upcoming = (events ?? [])
.map((e) => ({ ...e, isDone: new Date(e.eventDate) < new Date() }))
.filter((e) => !e.isDone)
@@ -234,7 +258,23 @@ export default function App() {
-
+ {eventsPending ? (
+
Loading events…
+ ) : eventsError && !events ? (
+
+
We couldn’t load the events.
+
+
+ ) : (
+
+ )}
[name] as const;
export function useCollection(name: Collection) {
return useQuery({
+ ...publicReadOptions,
queryKey: collectionKey(name),
- queryFn: () => api.get(`/${name}`).then((res) => res.data),
+ queryFn: ({ signal }) =>
+ api.get(`/${name}`, { timeout: 10000, signal }).then((res) => res.data),
});
}
function useInvalidate(name: Collection) {
const queryClient = useQueryClient();
return {
- onSuccess: () => queryClient.invalidateQueries({ queryKey: collectionKey(name) }),
+ onSuccess: () =>
+ queryClient.invalidateQueries({ queryKey: collectionKey(name) }),
onError: (err: unknown) => {
-
if (isAxiosError(err) && err.response?.status === 401) {
queryClient.setQueryData(ME_KEY, null);
}
@@ -45,7 +47,8 @@ export function useUpdate(name: Collection) {
export function useRemove(name: Collection) {
return useMutation({
- mutationFn: (id: string) => api.delete(`/admin/${name}/${id}`).then((res) => res.data),
+ mutationFn: (id: string) =>
+ api.delete(`/admin/${name}/${id}`).then((res) => res.data),
...useInvalidate(name),
});
}
diff --git a/client/src/hooks/useHomeContent.ts b/client/src/hooks/useHomeContent.ts
index aad25b6..c1a017e 100644
--- a/client/src/hooks/useHomeContent.ts
+++ b/client/src/hooks/useHomeContent.ts
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isAxiosError } from "axios";
-import { api } from "../lib/api";
+import { api, publicReadOptions } from "../lib/api";
import { ME_KEY } from "./useAuth";
import type { HomeContentValues } from "../schemas/content";
@@ -8,8 +8,12 @@ export const HOME_CONTENT_KEY = ["content", "home"] as const;
export function useHomeContent() {
return useQuery({
+ ...publicReadOptions,
queryKey: HOME_CONTENT_KEY,
- queryFn: () => api.get("/content/home").then((res) => res.data),
+ queryFn: ({ signal }) =>
+ api
+ .get("/content/home", { timeout: 10000, signal })
+ .then((res) => res.data),
});
}
@@ -17,12 +21,16 @@ export function useSaveHomeContent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (values: HomeContentValues) =>
- api.put("/admin/content/home", values).then((res) => res.data),
- // forces refetch so every render sees the new values
- onSuccess: () => queryClient.invalidateQueries({ queryKey: HOME_CONTENT_KEY }),
+ api
+ .put("/admin/content/home", values)
+ .then((res) => res.data),
+ onSuccess: async (values) => {
+ queryClient.setQueryData(HOME_CONTENT_KEY, values);
+ await queryClient.invalidateQueries({ queryKey: HOME_CONTENT_KEY });
+ },
onError: (err) => {
if (isAxiosError(err) && err.response?.status === 401) {
- // session died mid-edit -> nulls auth cache and AdminLayout redirects
+ // session died mid-edit -> nulls auth cache and AdminLayout redirects
queryClient.setQueryData(ME_KEY, null);
}
},
diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts
index 6a11fcb..e2b8e1c 100644
--- a/client/src/lib/api.ts
+++ b/client/src/lib/api.ts
@@ -8,3 +8,13 @@ export function apiErrorMessage(err: unknown): string {
}
return "Something went wrong";
}
+
+export const publicReadOptions = {
+ retry: (failureCount: number, error: Error) => {
+ if (failureCount >= 3) return false;
+ if (!isAxiosError(error)) return false;
+ const status = error.response?.status;
+ return !status || status === 408 || status === 429 || status >= 500;
+ },
+ retryDelay: (attempt: number) => Math.min(1000 * 2 ** attempt, 4000),
+};
diff --git a/fly.toml b/fly.toml
index 8db2f6f..03f56f8 100644
--- a/fly.toml
+++ b/fly.toml
@@ -10,7 +10,14 @@ primary_region = "syd"
auto_start_machines = true
min_machines_running = 0
+[[http_service.checks]]
+ grace_period = "10s"
+ interval = "15s"
+ method = "GET"
+ timeout = "5s"
+ path = "/api/health"
+
[[vm]]
memory = "256mb"
cpu_kind = "shared"
- cpus = 1
\ No newline at end of file
+ cpus = 1
diff --git a/server/src/app.ts b/server/src/app.ts
index f51e129..8b4441a 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -31,6 +31,10 @@ app.use(
);
app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || "1mb" }));
app.use(cookieParser());
+app.use("/api", (_req, res, next) => {
+ res.set("Cache-Control", "no-store");
+ next();
+});
app.use("/api", apiRateLimit);
app.use("/api/health", healthRoutes);
diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts
index 8c7725d..7ba4ec2 100644
--- a/server/src/routes/health.ts
+++ b/server/src/routes/health.ts
@@ -1,12 +1,20 @@
import { Router } from "express";
+import mongoose from "mongoose";
const router = Router();
-router.get("/", (_req, res) => {
- res.json({
- status: "ok",
- service: "umsa-api",
- });
+router.get("/", async (_req, res) => {
+ const db = mongoose.connection.db;
+ if (mongoose.connection.readyState !== 1 || !db) {
+ return res.status(503).json({ status: "unavailable", service: "umsa-api" });
+ }
+
+ try {
+ await db.command({ ping: 1 }, { timeoutMS: 2000 });
+ res.json({ status: "ok", service: "umsa-api" });
+ } catch {
+ res.status(503).json({ status: "unavailable", service: "umsa-api" });
+ }
});
export default router;
diff --git a/server/start.sh b/server/start.sh
new file mode 100644
index 0000000..40cfcef
--- /dev/null
+++ b/server/start.sh
@@ -0,0 +1,34 @@
+#!/bin/sh
+set -eu
+
+node dist/server.js &
+api_pid=$!
+nginx_pid=""
+
+cleanup() {
+ kill "$api_pid" ${nginx_pid:+"$nginx_pid"} 2>/dev/null || true
+}
+trap cleanup EXIT
+trap 'exit 143' TERM
+trap 'exit 130' INT
+
+attempt=0
+until wget -q -T 2 -O /dev/null http://127.0.0.1:5050/api/health; do
+ if ! kill -0 "$api_pid" 2>/dev/null; then
+ wait "$api_pid"
+ exit 1
+ fi
+ attempt=$((attempt + 1))
+ if [ "$attempt" -ge 30 ]; then
+ echo "API did not become ready" >&2
+ exit 1
+ fi
+ sleep 1
+done
+
+nginx -g 'daemon off;' &
+nginx_pid=$!
+while kill -0 "$api_pid" 2>/dev/null && kill -0 "$nginx_pid" 2>/dev/null; do
+ sleep 1
+done
+exit 1