From e23f803980a574bb0beb375a380d63af78f96e1b Mon Sep 17 00:00:00 2001 From: JasonA <49816459+jason301c@users.noreply.github.com> Date: Fri, 26 Sep 2025 16:29:09 +1000 Subject: [PATCH 1/2] Add lru-cache to job fetching (#233) Signed-off-by: dependabot[bot] Co-authored-by: ed Co-authored-by: monash-coding <64454397+monash-coding@users.noreply.github.com> Co-authored-by: oliverhuangcode <95962305+oliverhuangcode@users.noreply.github.com> Co-authored-by: Jerry Zhou <81694589+je-zhou@users.noreply.github.com> Co-authored-by: Brian-w-m <111062353+Brian-w-m@users.noreply.github.com> Co-authored-by: kyranaus Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: AdityaZDesai <119026042+AdityaZDesai@users.noreply.github.com> --- frontend/package-lock.json | 37 ++- frontend/package.json | 1 + frontend/src/actions/feedback.actions.ts | 92 ++++++ frontend/src/actions/jobs.fetch.ts | 284 ++++++++++++++++++ frontend/src/app/jobs/[id]/page.tsx | 2 +- frontend/src/app/jobs/page.tsx | 4 +- .../src/components/ui/feedback-button.tsx | 2 +- frontend/src/lib/utils.ts | 2 +- 8 files changed, 416 insertions(+), 8 deletions(-) create mode 100644 frontend/src/actions/feedback.actions.ts create mode 100644 frontend/src/actions/jobs.fetch.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d0e29b83..dbf255c7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,8 @@ "dompurify": "^3.2.3", "isomorphic-dompurify": "^2.22.0", "jsdom": "^26.0.0", + "lru-cache": "^11.2.2", + "mongodb": "^6.14.2", "next": "15.1.7", "pino": "^9.11.0", @@ -68,6 +70,12 @@ "lru-cache": "^10.4.3" } }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/@babel/runtime": { "version": "7.26.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz", @@ -2585,6 +2593,18 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.23.9", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", @@ -4576,10 +4596,13 @@ } }, "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } }, "node_modules/math-intrinsics": { "version": "1.1.0", @@ -5154,6 +5177,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8f22912a..13f22ab4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "dompurify": "^3.2.3", "isomorphic-dompurify": "^2.22.0", "jsdom": "^26.0.0", + "lru-cache": "^11.2.2", "mongodb": "^6.14.2", "next": "15.1.7", "pino": "^9.11.0", diff --git a/frontend/src/actions/feedback.actions.ts b/frontend/src/actions/feedback.actions.ts new file mode 100644 index 00000000..49f2d2d7 --- /dev/null +++ b/frontend/src/actions/feedback.actions.ts @@ -0,0 +1,92 @@ +"use server"; + +import logger from "@/lib/logger"; + +// Type for our form data +export interface FeedbackFormData { + email?: string; + message: string; +} + +export async function submitFeedback(data: FeedbackFormData) { + const databaseId = process.env.NOTION_DATABASE_ID; + const notionApiKey = process.env.NOTION_API_KEY; + + if (!databaseId) { + logger.error("NOTION_DATABASE_ID environment variable is not set"); + throw new Error("NOTION_DATABASE_ID environment variable is not set"); + } + + if (!notionApiKey) { + logger.error("NOTION_API_KEY environment variable is not set"); + throw new Error("NOTION_API_KEY environment variable is not set"); + } + + try { + const response = await fetch("https://api.notion.com/v1/pages", { + method: "POST", + headers: { + Authorization: `Bearer ${notionApiKey}`, + "Content-Type": "application/json", + "Notion-Version": "2022-06-28", + }, + body: JSON.stringify({ + parent: { + database_id: databaseId, + }, + properties: { + Email: { + title: [ + { + text: { + content: data.email || "Anonymous", + }, + }, + ], + }, + Feedback: { + rich_text: [ + { + text: { + content: data.message, + }, + }, + ], + }, + Date: { + date: { + start: new Date().toISOString(), + }, + }, + }, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + logger.error( + { error: errorData, status: response.status }, + "Notion API error during feedback submission", + ); + return { + success: false, + message: `We're sorry, but there was an issue submitting your feedback. Please try again later.`, + }; + } + + logger.info( + { email: data.email || "Anonymous" }, + "Feedback submitted successfully", + ); + return { + success: true, + message: "Thank you! Your feedback has been submitted successfully.", + }; + } catch (error) { + logger.error(error, "Unexpected error during feedback submission"); + return { + success: false, + message: `An unexpected error occurred while submitting your feedback. Please try again.`, + }; + } +} diff --git a/frontend/src/actions/jobs.fetch.ts b/frontend/src/actions/jobs.fetch.ts new file mode 100644 index 00000000..7ac1f23d --- /dev/null +++ b/frontend/src/actions/jobs.fetch.ts @@ -0,0 +1,284 @@ +import { MongoClient, ObjectId } from "mongodb"; +import { JobFilters } from "@/types/filters"; +import { Job } from "@/types/job"; +import serializeJob from "@/lib/utils"; +import logger from "@/lib/logger"; +import { LRUCache } from "lru-cache"; + +const PAGE_SIZE = 20; + +// Shared cache for job results +type CacheValue = { jobs: Job[]; total: number } | Job; + +const jobCache = new LRUCache({ + max: 500, + ttl: 1000 * 3600, // 1 hour + allowStale: false, +}); + +// Helper to normalize filters for cache key (handles string vs array for array fields) +function normalizeFiltersForKey( + filters: Partial, +): Record { + const arrayFields = [ + "workingRights[]", + "locations[]", + "industryFields[]", + "jobTypes[]", + ]; + const normalized: Record = { + search: (filters.search || "").toLowerCase().trim(), + page: (filters.page || 1).toString(), + }; + + arrayFields.forEach((field) => { + const val = filters[field as keyof Partial]; + if (val !== undefined) { + const arr = Array.isArray(val) + ? val + : typeof val === "string" + ? [val] + : []; + normalized[field] = arr.sort().join(","); + } + }); + + // Include other scalar fields if present + if (filters.jobTypes) + normalized.jobTypes = (filters.jobTypes as string[]).sort().join(","); + if (filters.locations) + normalized.locations = (filters.locations as string[]).sort().join(","); + // Add similar for others if needed, but since searchParams uses [] keys, prioritize those + + return normalized; +} + +/** + * Helper function to build a query object from filters. + * @param filters - The job filters from the client. + * @param additional - Additional query overrides (e.g. { is_sponsor: true }). + * @returns The query object to use with MongoDB. + */ +function buildJobQuery( + filters: Partial, + additional?: Record, +) { + const array_jobs = JSON.parse(JSON.stringify(filters, null, 2)); + const query = { + outdated: false, + ...(array_jobs["workingRights[]"] !== undefined && + array_jobs["workingRights[]"].length && { + working_rights: { + $in: Array.isArray(array_jobs["workingRights[]"]) + ? array_jobs["workingRights[]"] + : [array_jobs["workingRights[]"]], + }, + }), + ...(array_jobs["locations[]"] !== undefined && + array_jobs["locations[]"].length && { + locations: { + $in: Array.isArray(array_jobs["locations[]"]) + ? array_jobs["locations[]"] + : [array_jobs["locations[]"]], + }, + }), + ...(array_jobs["industryFields[]"] !== undefined && + array_jobs["industryFields[]"].length && { + industry_field: { + $in: Array.isArray(array_jobs["industryFields[]"]) + ? array_jobs["industryFields[]"] + : [array_jobs["industryFields[]"]], + }, + }), + ...(array_jobs["jobTypes[]"] !== undefined && + array_jobs["jobTypes[]"].length && { + type: { + $in: Array.isArray(array_jobs["jobTypes[]"]) + ? array_jobs["jobTypes[]"] + : [array_jobs["jobTypes[]"]], + }, + }), + ...(filters.search && { + $or: [ + { title: { $regex: filters.search, $options: "i" } }, + { "company.name": { $regex: filters.search, $options: "i" } }, + ], + }), + ...additional, + }; + return query; +} + +/** + * Helper function to manage a MongoDB connection. + * @param callback - The function that uses the connected MongoClient. + * @returns The result from the callback. + */ +async function withDbConnection( + callback: (client: MongoClient) => Promise, +): Promise { + if (!process.env.MONGODB_URI) { + logger.error("MONGODB_URI environment variable is not set"); + throw new Error( + "MongoDB URI is not configured. Please check environment variables.", + ); + } + const client = new MongoClient(process.env.MONGODB_URI); + try { + await client.connect(); + logger.debug("MongoDB connected successfully"); + return await callback(client); + } catch (error) { + logger.error(error, "Failed to connect to MongoDB or execute callback"); + throw error; + } finally { + await client.close(); + } +} + +/** + * Fetches paginated and filtered job listings from MongoDB. + */ +export async function getJobs( + filters: Partial, + minSponsors: number = -1, + prioritySponsors: Array = ["IMC", "Atlassian"], +): Promise<{ jobs: Job[]; total: number }> { + const page = filters.page || 1; + const normalizedFilters = normalizeFiltersForKey(filters); + const priorityStr = prioritySponsors.sort().join(","); + const cacheKey = `jobs:${JSON.stringify(normalizedFilters)}:${page}:${minSponsors}:${priorityStr}`; + + // Check cache first + const cached = jobCache.get(cacheKey); + if (cached) { + logger.debug({ cacheKey }, "Returning cached jobs"); + return cached as { jobs: Job[]; total: number }; + } + + logger.info( + { filters, minSponsors, prioritySponsors }, + "Fetching jobs with filters", + ); + + return await withDbConnection(async (client) => { + const collection = client.db("default").collection("active_jobs"); + const query = buildJobQuery(filters); + minSponsors = minSponsors === -1 ? (page == 1 ? 3 : 0) : minSponsors; + + try { + if (minSponsors == 0) { + const [jobs, total] = await Promise.all([ + collection + .find(query) + .sort({ created_at: -1 }) + .skip((page - 1) * PAGE_SIZE) + .limit(PAGE_SIZE) + .toArray(), + collection.countDocuments(query), + ]); + logger.debug({ total }, "Fetched non-sponsored jobs"); + const result = { + jobs: (jobs as MongoJob[]) + .map(serializeJob) + .map((job) => ({ ...job, highlight: false })), + total, + }; + jobCache.set(cacheKey, result); + return result; + } else { + const sponsoredQuery = { ...query, is_sponsored: true }; + + let sponsoredJobs = await collection + .aggregate([ + { $match: sponsoredQuery }, + { $sample: { size: minSponsors * 8 } }, + ]) + .toArray(); + + sponsoredJobs = sponsoredJobs + .filter((job) => { + const isPriority = prioritySponsors.includes(job.company.name); + return isPriority ? Math.random() < 0.65 : Math.random() >= 0.35; + }) + .slice(0, minSponsors) + .map((job) => ({ ...job, highlight: true })); + + const sponsoredJobIds = sponsoredJobs.map((job) => job._id); + + const filteredQuery = { ...query, _id: { $nin: sponsoredJobIds } }; + + const [otherJobs, total] = await Promise.all([ + collection + .find(filteredQuery) + .sort({ created_at: -1 }) + .skip((page - 1) * PAGE_SIZE) + .limit(PAGE_SIZE - sponsoredJobs.length) + .toArray(), + collection.countDocuments(query), + ]); + + const mergedJobs = [ + ...sponsoredJobs.map((job) => ({ ...job, highlight: true })), + ...otherJobs.map((job) => ({ ...job, highlight: false })), + ].slice(0, PAGE_SIZE); + + logger.debug( + { + sponsoredCount: sponsoredJobs.length, + otherCount: otherJobs.length, + total, + }, + "Fetched sponsored and other jobs", + ); + const result = { + jobs: (mergedJobs as MongoJob[]).map(serializeJob), + total, + }; + jobCache.set(cacheKey, result); + return result; + } + } catch (error) { + logger.error({ query, filters }, "Error fetching jobs"); + throw error; + } + }); +} + +/** + * Fetches a single job by its id. + */ +export async function getJobById(id: string): Promise { + const cacheKey = `job:${id}`; + + // Check cache first + const cached = jobCache.get(cacheKey); + if (cached) { + logger.debug({ id }, "Returning cached job"); + return cached as Job; + } + + logger.info({ id }, "Fetching job by ID"); + + return await withDbConnection(async (client) => { + const collection = client.db("default").collection("active_jobs"); + const job = await collection.findOne({ + _id: new ObjectId(id), + outdated: false, + }); + if (!job) { + logger.warn({ id }, "Job not found"); + return null; + } + logger.debug({ id }, "Job fetched successfully"); + const serializedJob = serializeJob(job as MongoJob); + jobCache.set(cacheKey, serializedJob); + return serializedJob; + }); +} + +// Define the MongoJob interface with the correct DB field names. +export interface MongoJob extends Omit { + _id: ObjectId; + is_sponsored: boolean; +} diff --git a/frontend/src/app/jobs/[id]/page.tsx b/frontend/src/app/jobs/[id]/page.tsx index 187a5a6b..ef232d91 100644 --- a/frontend/src/app/jobs/[id]/page.tsx +++ b/frontend/src/app/jobs/[id]/page.tsx @@ -1,6 +1,6 @@ // src/app/jobs/[id]/page.tsx -import { getJobById } from "@/app/jobs/actions"; +import { getJobById } from "@/actions/jobs.fetch"; import { notFound } from "next/navigation"; import { Job } from "@/types/job"; import JobDetailsWrapper from "@/components/jobs/job-details-wrapper"; diff --git a/frontend/src/app/jobs/page.tsx b/frontend/src/app/jobs/page.tsx index 99c5f88f..b89af992 100644 --- a/frontend/src/app/jobs/page.tsx +++ b/frontend/src/app/jobs/page.tsx @@ -3,7 +3,7 @@ import FilterSection from "@/components/filters/filter-section"; import JobList from "@/components/jobs/job-list"; import JobDetails from "@/components/jobs/job-details"; import { JobFilters } from "@/types/filters"; -import { getJobs } from "@/app/jobs/actions"; +import { getJobs } from "@/actions/jobs.fetch"; import NoResults from "@/components/ui/no-results"; import { Suspense } from "react"; import JobListLoading from "@/components/layout/job-list-loading"; @@ -13,6 +13,8 @@ export const metadata = { title: "Jobs", }; +export const revalidate = 3600; // 1 hour cache + export default async function JobsPage({ searchParams, }: { diff --git a/frontend/src/components/ui/feedback-button.tsx b/frontend/src/components/ui/feedback-button.tsx index eac1b9b6..101dacd0 100644 --- a/frontend/src/components/ui/feedback-button.tsx +++ b/frontend/src/components/ui/feedback-button.tsx @@ -12,7 +12,7 @@ import { } from "@mantine/core"; import { IconMessageCircle } from "@tabler/icons-react"; import { notifications } from "@mantine/notifications"; -import { submitFeedback } from "@/app/actions"; +import { submitFeedback } from "@/actions/feedback.actions"; export default function FeedbackButton() { const [opened, setOpened] = useState(false); diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 0dfcf6b0..c598b5b9 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1,6 +1,6 @@ import { FilterState } from "@/types/filters"; import { Job, WORKING_RIGHTS, WorkingRight } from "@/types/job"; -import { MongoJob } from "@/app/jobs/actions"; +import { MongoJob } from "@/actions/jobs.fetch"; /** * Creates a URL query string from a partial FilterState object. From 882b4a39eddc7f5018d73f038e85dc24e1c02dd4 Mon Sep 17 00:00:00 2001 From: Oliver Huang <95962305+oliverhuangcode@users.noreply.github.com> Date: Wed, 8 Jul 2026 02:51:57 +1000 Subject: [PATCH 2/2] Containerize webapp + deploy to Oracle/Dokploy via GHCR (#281) Co-authored-by: Claude Opus 4.8 --- .github/dependabot.yml | 2 +- .github/workflows/deploy.yml | 77 +++++++++++++++++++++++ .github/workflows/lint-checker.yml | 10 +-- docs/deploy.md | 99 ++++++++++++++++++++++++++++++ frontend/.dockerignore | 14 +++++ frontend/Dockerfile | 77 +++++++++++------------ frontend/next.config.ts | 8 +++ frontend/package-lock.json | 1 - 8 files changed, 243 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 docs/deploy.md create mode 100644 frontend/.dockerignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4bf823eb..d89ad8bc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -22,4 +22,4 @@ updates: - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "monthly" \ No newline at end of file + interval: "monthly" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..9d20cbc6 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,77 @@ +name: Build & publish image + +# Build the mploy webapp Docker image OFF the Oracle box (GitHub's runners do +# the heavy build) and push it to GHCR. Dokploy then just *pulls* the prebuilt +# image and runs it — so several apps can share one Oracle box without their +# builds fighting over RAM/CPU. See docs/deploy.md. + +on: + push: + branches: [production] + # Only rebuild when something that affects the image changes. + paths: + - "frontend/**" + - ".github/workflows/deploy.yml" + workflow_dispatch: {} # allow manual "Run workflow" + +env: + # NOTE: not ${{ github.repository }} — that's monashcoding/mploy-app. The + # published image name is deliberately just "mploy". + IMAGE: ghcr.io/monashcoding/mploy + +jobs: + build: + # Native ARM64 runner — Oracle Ampere is arm64, and this is free on public + # repos. Avoids ~5x slower QEMU cross-compilation. + runs-on: ubuntu-24.04-arm + permissions: + contents: read + packages: write # push to GHCR + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Image metadata (tags) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE }} + tags: | + type=raw,value=latest + type=sha,format=long + + - name: Build & push (linux/arm64) + uses: docker/build-push-action@v6 + with: + # Build context is frontend/ — the webapp is a self-contained npm app + # (not a workspace), so npm ci resolves from frontend/package-lock.json. + context: ./frontend + file: ./frontend/Dockerfile + platforms: linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + # No build-args: mploy has no NEXT_PUBLIC_* build-time vars, and + # `next build` doesn't touch the DB. All secrets are runtime env in + # Dokploy. + + # Tell Dokploy to pull the new image and redeploy. Create the app in + # Dokploy with provider "Docker" pointing at ghcr.io/monashcoding/mploy:latest, + # then copy its deploy webhook URL into the repo secret + # DOKPLOY_DEPLOY_WEBHOOK. Skipped automatically until that secret exists. + - name: Trigger Dokploy redeploy + env: + # Secrets can't be used directly in `if:`, so hoist into env first. + DOKPLOY_DEPLOY_WEBHOOK: ${{ secrets.DOKPLOY_DEPLOY_WEBHOOK }} + if: ${{ env.DOKPLOY_DEPLOY_WEBHOOK != '' }} + run: curl -fsSL -X POST "$DOKPLOY_DEPLOY_WEBHOOK" diff --git a/.github/workflows/lint-checker.yml b/.github/workflows/lint-checker.yml index 9ea83d6c..43e7199c 100644 --- a/.github/workflows/lint-checker.yml +++ b/.github/workflows/lint-checker.yml @@ -3,7 +3,7 @@ name: Frontend Lint Checker on: pull_request: paths: - - 'frontend/**' + - "frontend/**" jobs: verify: @@ -18,9 +18,9 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' - cache: 'npm' - cache-dependency-path: './frontend/package-lock.json' + node-version: "20" + cache: "npm" + cache-dependency-path: "./frontend/package-lock.json" - name: Install dependencies run: npm ci @@ -35,4 +35,4 @@ jobs: run: npx prettier --check . - name: Build - run: npm run build \ No newline at end of file + run: npm run build diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 00000000..6d39cecb --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,99 @@ +# Deploying mploy (Oracle Cloud + Dokploy) + +mploy (the MAC Jobs Board webapp) runs as a Docker container on an Oracle +Cloud VM, fronted by [Dokploy](https://dokploy.com). This mirrors the setup +used for our sibling repo `monmap`. + +## Architecture: build off-box, run on-box + +The Oracle VM is shared by several apps (monmap, mploy, monashcoding). A +Docker build peaks at 2–4 GB RAM and pegs the CPU; several racing on one box +is how you OOM production. So **we never build on the Oracle box**: + +``` +push to production ──▶ GitHub Actions (ubuntu-24.04-arm) + │ builds linux/arm64 image + ▼ + GHCR: ghcr.io/monashcoding/mploy:latest + │ Dokploy pulls (webhook-triggered) + ▼ + Oracle VM: `node server.js` (~200–400 MB idle) +``` + +The box only ever runs the finished containers. + +## This app's shape (why the config looks the way it does) + +- The webapp is `frontend/` — a **self-contained npm app** (not a pnpm + workspace), so the Docker **build context is `frontend/`** and deps install + with `npm ci` from `frontend/package-lock.json`. The Spring Boot `backend/` + is a separate service, not part of this image. +- Next.js is built with `output: "standalone"`; the runtime just runs + `node server.js`. `outputFileTracingRoot` is pinned to `frontend/` so the + standalone entrypoint always lands at `.next/standalone/server.js`. +- **No build-time vars.** There are no `NEXT_PUBLIC_*` values (the Google + Analytics id is hardcoded), and `next build` does **not** touch MongoDB — + every DB-backed route is `force-dynamic` or reads `searchParams`, so nothing + is prerendered against the database. Everything below is therefore a + **runtime** env var set in Dokploy; nothing is baked into the image. + +## One-time GitHub setup + +1. **Repo Secret** (Settings → Secrets and variables → Actions → _Secrets_): + - `DOKPLOY_DEPLOY_WEBHOOK` = the deploy webhook URL Dokploy generates for + the app (added after the Dokploy step below). Until it exists, the + workflow builds/pushes the image but skips the redeploy trigger. +2. **Make the GHCR package public** (or give Dokploy a read token) so the VM + can pull without auth: after the first push, open the package at + `github.com/orgs/monashcoding/packages` → Package settings → change + visibility to Public. + +There are no repo _Variables_ to set — the build takes no build-args. + +## One-time Dokploy setup + +1. **Create Application** → Provider: **Docker**. + - Image: `ghcr.io/monashcoding/mploy:latest` + - (If you kept the package private: add GHCR registry credentials — a + GitHub PAT with `read:packages`.) +2. **Environment** (runtime vars): + ``` + MONGODB_URI=mongodb+srv://:@/ + MONGODB_DATABASE=default + NEXTAUTH_SECRET= + NEXTAUTH_URL=https://jobs.monashcoding.com + GOOGLE_CLIENT_ID= + GOOGLE_CLIENT_SECRET= + NOTION_API_KEY= + NOTION_DATABASE_ID= + ``` + MongoDB is hosted externally (Atlas), so `MONGODB_URI` is a normal + `mongodb+srv://` connection string — no on-box private-IP caveat. Make sure + the Atlas cluster's IP access list allows the Oracle VM's egress IP. +3. **Port**: container listens on `3000`. +4. **Domains**: add `jobs.monashcoding.com` → container port `3000` → enable + HTTPS (Let's Encrypt). + - DNS is on Cloudflare. **Grey-cloud (DNS-only)** the record first so + Dokploy/Traefik can complete the Let's Encrypt HTTP-01 challenge and + issue the cert, then switch it back to **orange-cloud (proxied)** + afterwards. +5. **Deploy webhook**: copy the app's deploy webhook URL into the GitHub repo + secret `DOKPLOY_DEPLOY_WEBHOOK` (GitHub setup step 1) so each pushed image + auto-redeploys. + +## Deploying a change + +Push to `production`. GitHub Actions builds + pushes the image, then hits the +Dokploy webhook, which pulls and restarts the container. Watch the run under +the repo's Actions tab; watch the pull/restart in Dokploy. + +To deploy manually: Actions → **Build & publish image** → _Run workflow_, then +hit **Deploy** in Dokploy. + +## Notes + +- `next build` does **not** touch MongoDB, so `MONGODB_URI` is a runtime-only + var. There are no build-args at all. +- The container runs as a non-root user (`nextjs`, uid 1001). +- The image is `linux/arm64` only — it runs on the Ampere A1 box and won't run + on an x86 host without emulation. diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 00000000..a5ad00af --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,14 @@ +# Build context = frontend/. Keep the image lean and deterministic: never ship +# local deps/build output/secrets into the build. +node_modules +.next +npm-debug.log* +.env +.env.* +.git +.gitignore +Dockerfile +.dockerignore +README.md +tsconfig.tsbuildinfo +.DS_Store diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 890ef68d..1f4e07ae 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,52 +1,53 @@ -# ------------------------- -# Base -# ------------------------- -FROM node:20-alpine AS base +# syntax=docker/dockerfile:1 + +# --------------------------------------------------------------------------- +# mploy webapp — self-hosted image (Oracle Cloud + Dokploy). +# +# Build context is frontend/ (this directory). mploy's frontend is a +# self-contained npm app — NOT a pnpm workspace — so npm ci resolves +# everything from frontend/package-lock.json. The Spring Boot backend is a +# separate service and is not part of this image. +# +# There are NO build-time vars: the app has no NEXT_PUBLIC_* values (the GA id +# is hardcoded), and `next build` does NOT touch MongoDB (every DB route is +# force-dynamic or reads searchParams — nothing is prerendered against the DB). +# So everything secret (MONGODB_URI, NEXTAUTH_SECRET, OAuth creds, Notion keys) +# is a *runtime* env var, set in Dokploy — never baked into the image. +# --------------------------------------------------------------------------- + +FROM node:22-slim AS base WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 -# ------------------------- -# Dependencies -# ------------------------- +# ---- deps --------------------------------------------------------------- FROM base AS deps -COPY package.json package-lock.json* ./ +COPY package.json package-lock.json ./ RUN npm ci -# ------------------------- -# Builder -# ------------------------- -FROM base AS builder +# ---- build -------------------------------------------------------------- +FROM base AS build COPY --from=deps /app/node_modules ./node_modules -COPY package.json package-lock.json* ./ -COPY tsconfig.json next.config.ts ./ -COPY postcss.config.mjs tailwind.config.ts ./ -COPY public ./public -COPY src ./src +COPY . . RUN npm run build -# ------------------------- -# Runner -# ------------------------- -FROM base AS runner +# ---- runtime ------------------------------------------------------------ +FROM node:22-slim AS runner WORKDIR /app +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3000 \ + HOSTNAME=0.0.0.0 -# Default: prod, can override with `-e NODE_ENV=development` -ENV NODE_ENV=production +# Run as a non-root user. +RUN groupadd --system --gid 1001 nodejs \ + && useradd --system --uid 1001 --gid nodejs nextjs -# Copy runtime deps -COPY --from=deps /app/node_modules ./node_modules - -# Copy built assets -COPY --from=builder --chown=1001:1001 /app/.next/standalone ./ -COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static -COPY --from=builder --chown=1001:1001 /app/public ./public +# `output: "standalone"` bundles server.js + its traced node_modules. Static +# assets and public/ aren't traced, so copy them alongside. +COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=build --chown=nextjs:nodejs /app/public ./public -# Create non-root user -RUN addgroup --system --gid 1001 nodejs \ - && adduser --system --uid 1001 nextjs USER nextjs - EXPOSE 3000 -ENV PORT=3000 -ENV HOSTNAME=0.0.0.0 - -CMD ["npm", "start"] +CMD ["node", "server.js"] diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 68a6c64d..23387108 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,7 +1,15 @@ import type { NextConfig } from "next"; +import path from "path"; const nextConfig: NextConfig = { + // Emit a self-contained server bundle for the Docker runtime image + // (.next/standalone/server.js). See ../docs/deploy.md. output: "standalone", + // Pin file tracing to this app dir. Without it, the empty root + // package-lock.json makes Next infer the repo root as the tracing root and + // nest the standalone output under frontend/, breaking the Dockerfile CMD. + outputFileTracingRoot: path.join(__dirname), + serverExternalPackages: ["mongodb", "pino", "pino-pretty"], }; export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dbf255c7..24c5cc40 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,7 +20,6 @@ "isomorphic-dompurify": "^2.22.0", "jsdom": "^26.0.0", "lru-cache": "^11.2.2", - "mongodb": "^6.14.2", "next": "15.1.7", "pino": "^9.11.0",