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 ( +
+ Loading homepage… +
+ ); + } + + 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