# FOW Extended — STJ Job Portal: Technical Implementation Plan
**Version:** 1.0
**Date:** 2026-03-02
**Status:** Active — supersedes STJ-Technical-Approach.md (WordPress + WP Job Manager)
**Deployment Target:** `https://jobs.stj.gov` (on-premise Docker)
---
## Table of Contents
1. [Overview & Purpose](#1-overview--purpose)
2. [Authentication: Auth.js → Keycloak Migration](#2-authentication-authjs--keycloak-migration)
3. [New Prisma Schema Models](#3-new-prisma-schema-models)
4. [New API Routes](#4-new-api-routes)
5. [Employer ATS Dashboard Pages](#5-employer-ats-dashboard-pages)
6. [Youth-Facing Pages](#6-youth-facing-pages)
7. [PDF CV Generation](#7-pdf-cv-generation)
8. [Skills Sync — POST /api/skills/sync Detail](#8-skills-sync--post-apiskillssync-detail)
9. [STJ Bridge Service (Moodle → FOW Sync)](#9-stj-bridge-service-moodle--fow-sync)
10. [Job Matching Algorithm](#10-job-matching-algorithm)
11. [Job Alert Notification System](#11-job-alert-notification-system)
12. [Removing Vercel Dependencies](#12-removing-vercel-dependencies)
13. [Docker Configuration for FOW](#13-docker-configuration-for-fow)
14. [Environment Variables Reference](#14-environment-variables-reference)
15. [Phase Checklist](#15-phase-checklist)
---
## 1. Overview & Purpose
### Role of FOW Extended in the STJ Platform
FOW Extended is the central job portal for the Sierra Leone Skills-to-Job (STJ) initiative. It combines two complementary models within a single Next.js 14 application:
1. **Traditional Job Listings** — Employers post standard job openings; youth applicants browse, filter, and apply through a structured Applicant Tracking System (ATS) pipeline.
2. **Bounty Competitions** — The original FOW bounty model (project-based gig competitions) continues to operate unmodified alongside job listings.
FOW Extended integrates with:
- **Keycloak at `auth.stj.gov`** — single identity provider for all STJ platform users (youth, employers, admins)
- **Moodle LMS** — source of truth for skill certifications; synced to FOW via the STJ Bridge service every 30 minutes
- **STJ SRD Skill Taxonomy** — canonical list of skill categories used for job matching and alerts
### Why FOW Replaces WordPress + WP Job Manager
| Concern | WordPress + WP Job Manager | FOW Extended |
|---|---|---|
| Technology stack | PHP / MySQL / REST plugin | Next.js 14 / TypeScript / Prisma / PostgreSQL |
| Keycloak SSO | Requires third-party plugin, fragile | Native Auth.js OIDC integration |
| Moodle skill badges | No native concept | First-class `VerifiedSkill` model |
| Bounty model | Not supported | Already in codebase |
| ATS pipeline | Plugin-dependent, limited | Custom React Kanban board |
| PDF CV generation | Plugin or paid service | `@react-pdf/renderer` server-side |
| On-premise deployment | Possible but complex | Docker multi-stage build |
| Developer experience | Mixed PHP/JS | Pure TypeScript throughout |
### What Changes vs the Original FOW Codebase
- **Auth provider** replaced: Credentials + Google OAuth removed; Keycloak OIDC added
- **Four new Prisma models** added: `JobPosting`, `JobApplication`, `VerifiedSkill`, `JobAlert`
- **New API routes** under `/api/jobs/*`, `/api/skills/*`, `/api/alerts/*`, `/api/profile/*`
- **New pages** under `/employer/*`, `/jobs/*`, `/applications`, `/profile/*`
- **STJ Bridge** added as a companion Docker service (separate repo/folder)
- **Vercel-specific dependencies** removed; `standalone` Next.js output added for Docker
- All existing bounty logic remains untouched
---
## 2. Authentication: Auth.js → Keycloak Migration
### auth.ts — Full Replacement
Remove the existing Credentials and Google OAuth providers. Replace with a single Keycloak OIDC provider. The `signIn` callback handles first-login user provisioning.
```typescript
// src/auth.ts
import NextAuth, { type NextAuthConfig } from "next-auth";
import KeycloakProvider from "next-auth/providers/keycloak";
import { prisma } from "@/lib/prisma";
export const authConfig: NextAuthConfig = {
providers: [
KeycloakProvider({
clientId: process.env.KEYCLOAK_CLIENT_ID!,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
issuer: process.env.KEYCLOAK_ISSUER!, // https://auth.stj.gov/realms/stj
}),
],
session: { strategy: "jwt" },
callbacks: {
async signIn({ user, account, profile }) {
if (!profile?.sub) return false;
// Upsert user on first Keycloak login
await prisma.user.upsert({
where: { keycloakId: profile.sub },
create: {
keycloakId: profile.sub,
email: user.email ?? "",
name: user.name ?? "",
image: user.image ?? null,
},
update: {
email: user.email ?? "",
name: user.name ?? "",
},
});
return true;
},
async jwt({ token, account, profile }) {
if (profile) {
// Extract custom claim set by Keycloak client mapper
token.stj_role = (profile as Record<string, unknown>).stj_role as string | undefined;
token.keycloakId = profile.sub;
}
if (account?.access_token) {
token.accessToken = account.access_token;
}
return token;
},
async session({ session, token }) {
session.user.role = token.stj_role as string | undefined;
session.user.keycloakId = token.keycloakId as string | undefined;
session.accessToken = token.accessToken as string | undefined;
return session;
},
},
};
export const { handlers, signIn, signOut, auth } = NextAuth(authConfig);
```
**TypeScript augmentation** — add to `src/types/next-auth.d.ts`:
```typescript
import "next-auth";
declare module "next-auth" {
interface Session {
accessToken?: string;
user: {
role?: string;
keycloakId?: string;
} & DefaultSession["user"];
}
}
```
### Required Environment Variables
```bash
KEYCLOAK_CLIENT_ID=fow-jobs
KEYCLOAK_CLIENT_SECRET=<secret>
KEYCLOAK_ISSUER=https://auth.stj.gov/realms/stj
NEXTAUTH_URL=https://jobs.stj.gov
NEXTAUTH_SECRET=<random-32-chars>
```
### Middleware — Role-Based Route Protection
```typescript
// src/middleware.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
const ROLE_ROUTES: Record<string, string[]> = {
"/employer": ["employer", "platform_admin"],
"/admin": ["platform_admin"],
"/apply": ["youth"],
};
export default auth((req) => {
const { pathname } = req.nextUrl;
const role = req.auth?.user?.role;
for (const [prefix, allowedRoles] of Object.entries(ROLE_ROUTES)) {
if (pathname.startsWith(prefix)) {
if (!role || !allowedRoles.includes(role)) {
const loginUrl = new URL("/api/auth/signin", req.url);
loginUrl.searchParams.set("callbackUrl", req.url);
return NextResponse.redirect(loginUrl);
}
}
}
return NextResponse.next();
});
export const config = {
matcher: ["/employer/:path*", "/admin/:path*", "/apply/:path*"],
};
```
---
## 3. New Prisma Schema Models
Add the following to `prisma/schema.prisma`. Existing models are unchanged.
```prisma
// ── Enums ────────────────────────────────────────────────────────────────────
enum LocationType {
ONSITE
REMOTE
HYBRID
}
enum EmploymentType {
FULL_TIME
PART_TIME
INTERNSHIP
CONTRACT
}
enum JobStatus {
DRAFT
ACTIVE
FILLED
CLOSED
}
enum ApplicationStatus {
SUBMITTED
UNDER_REVIEW
SHORTLISTED
INTERVIEW_SCHEDULED
HIRED
REJECTED
}
// ── Models ───────────────────────────────────────────────────────────────────
model JobPosting {
id String @id @default(cuid())
companyId String
company CompanyProfile @relation(fields: [companyId], references: [id])
title String
description String @db.Text
requiredSkills String[]
location String
locationType LocationType
employmentType EmploymentType
salaryMin Int?
salaryMax Int?
currency String @default("SLE")
deadline DateTime
status JobStatus @default(DRAFT)
applications JobApplication[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model JobApplication {
id String @id @default(cuid())
jobId String
job JobPosting @relation(fields: [jobId], references: [id])
applicantId String
applicant User @relation(fields: [applicantId], references: [id])
coverLetter String? @db.Text
status ApplicationStatus @default(SUBMITTED)
employerNotes String? @db.Text
interviewDate DateTime?
appliedAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model VerifiedSkill {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
moodleCertId String @unique
courseName String
skillCategory String
issuedAt DateTime
syncedAt DateTime @default(now())
}
model JobAlert {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
keywords String[]
location String?
skillCategory String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
}
```
**Additions to the existing `User` model** — add these relation fields:
```prisma
model User {
// ... existing fields ...
keycloakId String? @unique
applications JobApplication[]
verifiedSkills VerifiedSkill[]
jobAlerts JobAlert[]
}
```
**Run migration after schema changes:**
```bash
npx prisma migrate dev --name add_job_portal_models
npx prisma generate
```
---
## 4. New API Routes
All routes live under `src/app/api/`. Role checks use the `auth()` helper from `src/auth.ts`.
### Job Listings
#### `POST /api/jobs` — Create Job Posting
- **Role:** `employer` or `platform_admin`
- **Request body:**
```typescript
interface CreateJobBody {
title: string;
description: string;
requiredSkills: string[];
location: string;
locationType: "ONSITE" | "REMOTE" | "HYBRID";
employmentType: "FULL_TIME" | "PART_TIME" | "INTERNSHIP" | "CONTRACT";
salaryMin?: number;
salaryMax?: number;
deadline: string; // ISO 8601
}
```
- **Response:** `201 { job: JobPosting }`
- **Logic:** Resolve `companyId` from the authenticated user's linked `CompanyProfile`. Validate deadline is in the future. Create with `status: DRAFT`; employer must explicitly set to `ACTIVE`.
#### `GET /api/jobs` — Browse Job Listings (Public)
- **Role:** Public (no auth required)
- **Query params:** `?keyword=&location=&skillCategory=&employmentType=&page=1&limit=20`
- **Response:** `200 { jobs: JobPosting[], total: number, page: number, totalPages: number }`
- **Logic:** Filter by `status: ACTIVE` always. Full-text search on `title` + `description` if `keyword` provided. Filter `requiredSkills` array contains `skillCategory` if provided. Paginate with `skip`/`take`.
#### `GET /api/jobs/[id]` — Job Detail (Public)
- **Role:** Public
- **Response:** `200 { job: JobPosting & { company: CompanyProfile, _count: { applications } } }`
#### `PATCH /api/jobs/[id]` — Update Job Posting
- **Role:** `employer` (must own the job) or `platform_admin`
- **Request body:** Partial `CreateJobBody` plus `status?: JobStatus`
- **Response:** `200 { job: JobPosting }`
- **Logic:** Verify `job.company.userId === session.user.id` OR role is `platform_admin`.
#### `DELETE /api/jobs/[id]` — Close Job
- **Role:** `employer` (must own) or `platform_admin`
- **Response:** `200 { success: true }`
- **Logic:** Sets `status: CLOSED` (soft delete). Does not remove the record.
### Applications
#### `POST /api/jobs/[id]/apply` — Submit Application
- **Role:** `youth`
- **Request body:** `{ coverLetter?: string }`
- **Response:** `201 { application: JobApplication }`
- **Logic:** Check job is `ACTIVE` and before `deadline`. Prevent duplicate applications (unique check on `jobId + applicantId`).
#### `GET /api/jobs/[id]/applications` — List Applicants
- **Role:** `employer` (must own job) or `platform_admin`
- **Response:** `200 { applications: (JobApplication & { applicant: User & { verifiedSkills, bountyWins } })[] }`
#### `PATCH /api/jobs/[id]/applications/[aid]` — Update Application Status
- **Role:** `employer` (must own job)
- **Request body:** `{ status: ApplicationStatus; employerNotes?: string; interviewDate?: string }`
- **Response:** `200 { application: JobApplication }`
#### `POST /api/jobs/[id]/applications/[aid]/invite` — Send Interview Invite
- **Role:** `employer` (must own job)
- **Request body:** `{ interviewDate: string; message?: string }`
- **Response:** `200 { success: true }`
- **Logic:** Update `interviewDate` on the application; set status to `INTERVIEW_SCHEDULED`; create a `Notification` record for the applicant; send email via Nodemailer.
### Profile & CV
#### `GET /api/profile/[userId]/cv` — Generate PDF CV
- **Role:** Authenticated (own profile) or `platform_admin`
- **Query:** `?format=pdf`
- **Response:** Binary PDF with headers:
```
Content-Type: application/pdf
Content-Disposition: attachment; filename="[name]-cv.pdf"
```
- **Logic:** Fetch user + verifiedSkills + bountyWins; render React-PDF document server-side; stream buffer.
### Skills Sync (Internal — STJ Bridge)
#### `POST /api/skills/sync`
- **Auth:** `Authorization: Bearer <STJ_BRIDGE_API_KEY>` (static API key, not Keycloak)
- **Request body:**
```typescript
interface SkillSyncPayload {
keycloakUserId: string;
certId: string;
courseName: string;
skillCategory: string;
issuedAt: string; // ISO 8601
}
```
- **Response:** `200 { synced: true, userId: string, certId: string }`
- **Logic:** See [Section 8](#8-skills-sync--post-apiskillssync-detail) for full walkthrough.
### Job Recommendations
#### `GET /api/jobs/recommendations`
- **Role:** `youth`
- **Response:** `200 { jobs: (JobPosting & { matchScore: number })[] }`
- **Logic:** See [Section 10](#10-job-matching-algorithm).
### Job Alerts
#### `POST /api/alerts`
- **Role:** Authenticated
- **Request body:** `{ keywords?: string[]; location?: string; skillCategory?: string }`
- **Response:** `201 { alert: JobAlert }`
#### `GET /api/alerts`
- **Role:** Authenticated
- **Response:** `200 { alerts: JobAlert[] }`
#### `PATCH /api/alerts/[id]`
- **Role:** Authenticated (own alert)
- **Request body:** Partial `JobAlert` fields including `isActive`
- **Response:** `200 { alert: JobAlert }`
### Health Check
#### `GET /api/health`
- **Role:** Public
- **Response:**
```json
{
"status": "ok",
"timestamp": "2026-03-02T12:00:00.000Z",
"version": "1.0.0"
}
```
```typescript
// src/app/api/health/route.ts
import { NextResponse } from "next/server";
import pkg from "@/../../package.json";
export async function GET() {
return NextResponse.json({
status: "ok",
timestamp: new Date().toISOString(),
version: pkg.version,
});
}
```
---
## 5. Employer ATS Dashboard Pages
All pages are under `src/app/employer/` and are server components by default. Client components handle interactivity.
### `/employer/jobs` — Job Postings List
Table columns: **Title** | **Status** (badge) | **Applicants** (count) | **Deadline** | **Actions** (Edit, View Applicants, Close).
Fetch with:
```typescript
const jobs = await prisma.jobPosting.findMany({
where: { company: { userId: session.user.id } },
include: { _count: { select: { applications: true } } },
orderBy: { createdAt: "desc" },
});
```
### `/employer/jobs/new` — Create Job Posting Form
Client component with `react-hook-form` + `zod` validation. Fields:
| Field | Input Type | Notes |
|---|---|---|
| Title | text | required |
| Description | rich text (Tiptap) | required |
| Required Skills | multi-select | sourced from STJ SRD taxonomy |
| Location | text | city / region |
| Location Type | radio | ONSITE / REMOTE / HYBRID |
| Employment Type | select | FULL_TIME / PART_TIME / INTERNSHIP / CONTRACT |
| Salary Min / Max | number | optional; currency fixed to SLE |
| Application Deadline | date picker | must be future date |
On submit: calls `POST /api/jobs`, then redirects to `/employer/jobs`.
### `/employer/jobs/[id]/applicants` — ATS Pipeline (Kanban)
Client component. Columns map to `ApplicationStatus` values:
```
SUBMITTED → UNDER_REVIEW → SHORTLISTED → INTERVIEW_SCHEDULED → HIRED / REJECTED
```
Each card displays:
- Applicant name + photo
- Top 3 verified Moodle skill badges (highlighted in teal)
- Application date
Drag-and-drop implemented with `@hello-pangea/dnd` (maintained fork of `react-beautiful-dnd`). On drop, calls `PATCH /api/jobs/[id]/applications/[aid]` with the new status.
### `/employer/jobs/[id]/applicants/[aid]` — Full Applicant Profile
Sections:
1. **Personal Info** — name, email, phone, location
2. **Education & Work Experience** — from applicant's profile
3. **Moodle Verified Skills** — `VerifiedSkill` records rendered as badges with cert ID and issue date
4. **FOW Bounty Wins** — if any exist on the applicant's profile
5. **Cover Letter** — full text
6. **Employer Notes** — private textarea; auto-saves via `PATCH` on blur
Action buttons:
- Shortlist
- Reject
- Schedule Interview (opens modal with date picker, then calls `/invite` endpoint)
- Mark Hired
---
## 6. Youth-Facing Pages
### `/jobs` — Job Listings Browse
Left sidebar filters: Keyword, Location, Skill Category (SRD taxonomy), Employment Type, Location Type. Results grid (cards) with: job title, company name, location type badge, employment type badge, top 2 required skills, deadline. Pagination at bottom.
### `/jobs/[id]` — Job Detail
Full job description, requirements, salary range (if set), company info. **Apply** button visible only to authenticated `youth` users; redirects to sign-in if not authenticated.
### `/jobs/[id]/apply` — Application Form
1. Confirm profile completeness (name, email, phone — readonly from Keycloak session)
2. Cover letter textarea (optional but encouraged)
3. List of applicant's `VerifiedSkill` records (auto-attached to application)
4. Submit button calls `POST /api/jobs/[id]/apply`
### `/profile/[userId]` — Enhanced Youth Profile
Sections:
- **Header** — avatar, name, current title, location, LinkedIn link
- **About** — bio / summary
- **Education** — degree, institution, year
- **Work Experience** — employer, role, dates, description
- **Moodle Verified Skills** — grid of badge cards; each shows course name, skill category, issue date, cert ID with external verify link
- **Bounty Wins** — project name, prize amount, date
- **Download CV** button — calls `GET /api/profile/[userId]/cv?format=pdf`
### `/applications` — Application History
Table: **Job Title** | **Company** | **Applied Date** | **Status** (colored badge) | **Actions** (View Job, Withdraw if SUBMITTED).
---
## 7. PDF CV Generation
**Library:** `@react-pdf/renderer`
**Install:**
```bash
npm install @react-pdf/renderer
npm install --save-dev @types/react-pdf
```
**CV Document Component:**
```typescript
// src/components/cv/CvDocument.tsx
import {
Document, Page, Text, View, StyleSheet, Font,
} from "@react-pdf/renderer";
const styles = StyleSheet.create({
page: { padding: 40, fontFamily: "Helvetica", fontSize: 10, lineHeight: 1.5 },
header: { marginBottom: 16 },
name: { fontSize: 20, fontWeight: "bold" },
section: { marginBottom: 12 },
sectionTitle: { fontSize: 12, fontWeight: "bold", borderBottomWidth: 1,
borderColor: "#cccccc", paddingBottom: 2, marginBottom: 6 },
badge: { backgroundColor: "#e0f2fe", padding: "2 6", borderRadius: 4,
marginRight: 4, marginBottom: 4, fontSize: 9 },
row: { flexDirection: "row", flexWrap: "wrap" },
});
export function CvDocument({ user, verifiedSkills, bountyWins }: CvProps) {
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.header}>
<Text style={styles.name}>{user.name}</Text>
<Text>{user.email} · {user.phone} · {user.location}</Text>
{user.linkedin && <Text>{user.linkedin}</Text>}
</View>
{user.bio && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Professional Summary</Text>
<Text>{user.bio}</Text>
</View>
)}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Moodle Verified Skills</Text>
<View style={styles.row}>
{verifiedSkills.map((s) => (
<View key={s.moodleCertId} style={styles.badge}>
<Text>{s.courseName} ({s.skillCategory}) — {
new Date(s.issuedAt).toLocaleDateString()
} | Cert: {s.moodleCertId}</Text>
</View>
))}
</View>
</View>
{bountyWins.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>FOW Bounty Wins</Text>
{bountyWins.map((b) => (
<Text key={b.id}>{b.projectName} — {b.prize} SLE ({
new Date(b.completedAt).toLocaleDateString()
})</Text>
))}
</View>
)}
</Page>
</Document>
);
}
```
**API Route — Server-Side PDF Rendering:**
```typescript
// src/app/api/profile/[userId]/cv/route.ts
import { renderToBuffer } from "@react-pdf/renderer";
import { CvDocument } from "@/components/cv/CvDocument";
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
import { createElement } from "react";
export async function GET(
_req: Request,
{ params }: { params: { userId: string } }
) {
const session = await auth();
if (!session || (session.user.keycloakId !== params.userId
&& session.user.role !== "platform_admin")) {
return new Response("Unauthorized", { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: params.userId },
include: { verifiedSkills: true },
});
if (!user) return new Response("Not found", { status: 404 });
const bountyWins = await prisma.submission.findMany({
where: { userId: params.userId, status: "WINNER" },
include: { bounty: true },
});
const buffer = await renderToBuffer(
createElement(CvDocument, { user, verifiedSkills: user.verifiedSkills, bountyWins })
);
const safeName = (user.name ?? "cv").replace(/\s+/g, "-").toLowerCase();
return new Response(buffer, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${safeName}-cv.pdf"`,
},
});
}
```
---
## 8. Skills Sync — `POST /api/skills/sync` Detail
Full implementation walkthrough:
```typescript
// src/app/api/skills/sync/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function POST(req: NextRequest) {
// Step 1: Validate Bearer token (static API key)
const authHeader = req.headers.get("authorization") ?? "";
const token = authHeader.replace("Bearer ", "");
if (token !== process.env.STJ_BRIDGE_API_KEY) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body: SkillSyncPayload = await req.json();
const { keycloakUserId, certId, courseName, skillCategory, issuedAt } = body;
// Step 2: Resolve keycloakUserId → User.id
const user = await prisma.user.findUnique({ where: { keycloakId: keycloakUserId } });
if (!user) {
// User hasn't logged in yet — Bridge will retry next cycle
return NextResponse.json(
{ synced: false, reason: "user_not_found" },
{ status: 404 }
);
}
// Step 3: Upsert VerifiedSkill (unique on moodleCertId)
await prisma.verifiedSkill.upsert({
where: { moodleCertId: certId },
create: {
userId: user.id,
moodleCertId: certId,
courseName,
skillCategory,
issuedAt: new Date(issuedAt),
},
update: { syncedAt: new Date() },
});
// Step 4: Re-score job recommendations (async — don't block response)
rescoreRecommendations(user.id).catch(console.error);
// Step 5: Check JobAlerts for this user
const alerts = await prisma.jobAlert.findMany({
where: { userId: user.id, isActive: true },
});
for (const alert of alerts) {
if (alert.skillCategory === skillCategory ||
alert.keywords.some((k) => courseName.toLowerCase().includes(k.toLowerCase()))) {
await triggerJobAlertNotification(alert, user, skillCategory);
}
}
return NextResponse.json({ synced: true, userId: user.id, certId });
}
async function rescoreRecommendations(userId: string) {
const skills = await prisma.verifiedSkill.findMany({ where: { userId } });
const categories = skills.map((s) => s.skillCategory);
// Active jobs whose requiredSkills overlap with user's categories
await prisma.jobPosting.findMany({
where: {
status: "ACTIVE",
requiredSkills: { hasSome: categories },
},
});
// Scores are computed at query time in /api/jobs/recommendations; no persistent score needed
}
```
---
## 9. STJ Bridge Service (Moodle → FOW Sync)
The Bridge is a standalone TypeScript service packaged as a Docker container. It polls Moodle every 30 minutes and calls FOW's `/api/skills/sync` for each new certificate.
### File Structure
```
stj-bridge/
package.json
tsconfig.json
Dockerfile
src/
index.ts
moodle-client.ts
fow-client.ts
db.ts # SQLite for last-sync timestamp
```
### `stj-bridge/package.json`
```json
{
"name": "stj-bridge",
"version": "1.0.0",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts"
},
"dependencies": {
"axios": "^1.7.0",
"better-sqlite3": "^9.4.0",
"dotenv": "^16.4.0",
"node-cron": "^3.0.3"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.0",
"@types/node": "^20.0.0",
"@types/node-cron": "^3.0.0",
"typescript": "^5.4.0",
"ts-node": "^10.9.0"
}
}
```
### `stj-bridge/src/moodle-client.ts`
```typescript
import axios from "axios";
const base = process.env.MOODLE_BASE_URL!;
const token = process.env.MOODLE_API_TOKEN!;
export interface MoodleCert {
id: number;
userid: number;
coursename: string;
skillcategory: string;
timecreated: number; // Unix timestamp
}
export interface MoodleUser {
id: number;
email: string;
username: string;
}
async function moodleCall(wsfunction: string, params: Record<string, unknown>) {
const res = await axios.post(`${base}/webservice/rest/server.php`, null, {
params: { wstoken: token, moodlewsrestformat: "json", wsfunction, ...params },
});
return res.data;
}
export async function getCertificates(since: Date): Promise<MoodleCert[]> {
const data = await moodleCall("tool_certificate_get_certificates", {
since: Math.floor(since.getTime() / 1000),
});
return data.certificates ?? [];
}
export async function getUserByMoodleId(moodleUserId: number): Promise<MoodleUser | null> {
const data = await moodleCall("core_user_get_users", {
"criteria[0][key]": "id",
"criteria[0][value]": moodleUserId,
});
return data.users?.[0] ?? null;
}
```
### `stj-bridge/src/fow-client.ts`
```typescript
import axios, { AxiosError } from "axios";
const fowBase = process.env.FOW_BASE_URL!;
const apiKey = process.env.FOW_BRIDGE_API_KEY!;
export interface SkillSyncPayload {
keycloakUserId: string;
certId: string;
courseName: string;
skillCategory: string;
issuedAt: string;
}
export async function syncSkill(payload: SkillSyncPayload, retries = 3): Promise<void> {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
await axios.post(`${fowBase}/api/skills/sync`, payload, {
headers: { Authorization: `Bearer ${apiKey}` },
timeout: 10_000,
});
return;
} catch (err) {
const delay = 1000 * 2 ** (attempt - 1); // exponential backoff: 1s, 2s, 4s
if (attempt < retries) {
console.warn(`Retry ${attempt}/${retries} for cert ${payload.certId} in ${delay}ms`);
await new Promise((r) => setTimeout(r, delay));
} else {
const msg = err instanceof AxiosError ? err.response?.data : String(err);
console.error(`Failed to sync cert ${payload.certId} after ${retries} attempts:`, msg);
}
}
}
}
```
### `stj-bridge/src/index.ts` — Main Cron Loop
```typescript
import "dotenv/config";
import cron from "node-cron";
import { getCertificates, getUserByMoodleId } from "./moodle-client";
import { syncSkill } from "./fow-client";
import { getLastSync, setLastSync } from "./db";
const INTERVAL = process.env.SYNC_INTERVAL_MINUTES ?? "30";
async function runSync() {
console.log(`[Bridge] Sync started at ${new Date().toISOString()}`);
const lastSync = getLastSync();
let processed = 0;
let skipped = 0;
try {
const certs = await getCertificates(lastSync);
console.log(`[Bridge] Found ${certs.length} new certificates since ${lastSync.toISOString()}`);
for (const cert of certs) {
const moodleUser = await getUserByMoodleId(cert.userid);
if (!moodleUser) {
console.warn(`[Bridge] Moodle user ${cert.userid} not found, skipping cert ${cert.id}`);
skipped++;
continue;
}
// keycloakId resolved by matching email (Keycloak uses same email)
await syncSkill({
keycloakUserId: moodleUser.email, // FOW resolves by email → keycloakId
certId: String(cert.id),
courseName: cert.coursename,
skillCategory: cert.skillcategory,
issuedAt: new Date(cert.timecreated * 1000).toISOString(),
});
processed++;
}
setLastSync(new Date());
console.log(`[Bridge] Sync complete. Processed: ${processed}, Skipped: ${skipped}`);
} catch (err) {
console.error("[Bridge] Fatal sync error:", err);
}
}
// Run immediately on startup, then on schedule
runSync();
cron.schedule(`*/${INTERVAL} * * * *`, runSync);
```
### `stj-bridge/Dockerfile`
```dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json tsconfig.json ./
RUN npm ci
COPY src ./src
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]
```
---
## 10. Job Matching Algorithm
```typescript
// src/app/api/jobs/recommendations/route.ts
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
export async function GET() {
const session = await auth();
if (!session || session.user.role !== "youth") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Step 1: Load all verified skill categories for this user
const skills = await prisma.verifiedSkill.findMany({
where: { user: { keycloakId: session.user.keycloakId } },
select: { skillCategory: true },
});
const userCategories = [...new Set(skills.map((s) => s.skillCategory))];
if (userCategories.length === 0) {
return NextResponse.json({ jobs: [] });
}
// Step 2: Query active jobs with overlapping requiredSkills
const jobs = await prisma.jobPosting.findMany({
where: {
status: "ACTIVE",
deadline: { gte: new Date() },
requiredSkills: { hasSome: userCategories },
},
include: { company: true },
});
// Step 3: Score by overlap count
const scored = jobs
.map((job) => ({
...job,
matchScore: job.requiredSkills.filter((s) => userCategories.includes(s)).length,
}))
.sort((a, b) =>
b.matchScore !== a.matchScore
? b.matchScore - a.matchScore
: b.createdAt.getTime() - a.createdAt.getTime()
)
.slice(0, 20); // Top 20
return NextResponse.json({ jobs: scored });
}
```
---
## 11. Job Alert Notification System
Alerts are checked in two places:
1. **`POST /api/skills/sync`** — when a new skill is synced for a user
2. **`POST /api/jobs`** — when a new job posting is created and activated
```typescript
// src/lib/alerts.ts
import { prisma } from "@/lib/prisma";
import { sendEmail } from "@/lib/email";
export async function triggerJobAlertNotification(
alert: JobAlert,
user: User,
matchedCategory: string
) {
// Find matching active jobs
const matchingJobs = await prisma.jobPosting.findMany({
where: {
status: "ACTIVE",
requiredSkills: { has: matchedCategory },
},
take: 5,
});
if (matchingJobs.length === 0) return;
// Create in-app notification
await prisma.notification.create({
data: {
userId: user.id,
type: "JOB_ALERT",
title: `New jobs match your ${matchedCategory} skills`,
body: `${matchingJobs.length} new job(s) match your skills. Check them out!`,
link: `/jobs?skillCategory=${encodeURIComponent(matchedCategory)}`,
},
});
// Send email notification
await sendEmail({
to: user.email,
subject: `STJ Jobs — New ${matchedCategory} opportunities`,
html: buildAlertEmailHtml(user.name, matchingJobs, matchedCategory),
});
}
```
**Nodemailer setup (`src/lib/email.ts`):**
```typescript
import nodemailer from "nodemailer";
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
export async function sendEmail(opts: { to: string; subject: string; html: string }) {
await transporter.sendMail({ from: process.env.SMTP_FROM, ...opts });
}
```
In-app notifications are fetched on each page load via `GET /api/notifications` and displayed as a navbar badge count. The `Notification` model (add to schema) has fields: `id`, `userId`, `type`, `title`, `body`, `link`, `read` (default `false`), `createdAt`.
---
## 12. Removing Vercel Dependencies
| Change | Action |
|---|---|
| `vercel.json` | Delete the file |
| `next.config.js` `output` | Set `output: "standalone"` |
| `experimental.runtime: "edge"` | Remove all edge runtime declarations |
| `@vercel/analytics` | Remove package; delete any `<Analytics />` component |
| `@vercel/og` | Replace with `@vercel/og` → `satori` + `resvg-js` self-hosted, or remove OG routes |
| Cloudinary (if used) | Replace with MinIO via `@aws-sdk/client-s3`; update upload/download helpers |
| Image optimization | Add `sharp` package: `npm install sharp` |
**`next.config.js` after changes:**
```javascript
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
images: {
unoptimized: false, // sharp handles optimization
remotePatterns: [
{ protocol: "https", hostname: "minio.stj.gov" },
],
},
};
module.exports = nextConfig;
```
**MinIO upload helper (`src/lib/storage.ts`):**
```typescript
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
export const s3 = new S3Client({
endpoint: process.env.MINIO_ENDPOINT,
region: "us-east-1", // MinIO ignores this but SDK requires it
credentials: {
accessKeyId: process.env.MINIO_ACCESS_KEY!,
secretAccessKey: process.env.MINIO_SECRET_KEY!,
},
forcePathStyle: true, // required for MinIO
});
export async function uploadFile(key: string, body: Buffer, contentType: string) {
await s3.send(new PutObjectCommand({
Bucket: process.env.MINIO_BUCKET,
Key: key,
Body: body,
ContentType: contentType,
}));
}
export async function getPresignedUrl(key: string, expiresIn = 3600) {
return getSignedUrl(s3, new GetObjectCommand({
Bucket: process.env.MINIO_BUCKET,
Key: key,
}), { expiresIn });
}
```
---
## 13. Docker Configuration for FOW
### `Dockerfile` (FOW Application)
```dockerfile
# Stage 1 — Builder
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY prisma ./prisma
RUN npx prisma generate
COPY . .
RUN npm run build
# Stage 2 — Runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Copy standalone output
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD wget -qO- http://localhost:3000/api/health || exit 1
CMD ["node", "server.js"]
```
### `docker-compose.yml` — FOW Service Snippet
```yaml
services:
fow:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
DATABASE_URL: postgresql://fow:${POSTGRES_PASSWORD}@postgres:5432/fow
NEXTAUTH_URL: https://jobs.stj.gov
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
KEYCLOAK_CLIENT_ID: fow-jobs
KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET}
KEYCLOAK_ISSUER: https://auth.stj.gov/realms/stj
REDIS_URL: redis://redis:6379
MINIO_ENDPOINT: http://minio:9000
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
MINIO_BUCKET: stj-uploads
STJ_BRIDGE_API_KEY: ${STJ_BRIDGE_API_KEY}
SMTP_HOST: ${SMTP_HOST}
SMTP_PORT: ${SMTP_PORT}
SMTP_USER: ${SMTP_USER}
SMTP_PASS: ${SMTP_PASS}
SMTP_FROM: noreply@stj.gov
NEXT_PUBLIC_APP_URL: https://jobs.stj.gov
# Port not exposed externally — Nginx proxies to this container
networks:
- stj-internal
stj-bridge:
build:
context: ./stj-bridge
dockerfile: Dockerfile
restart: unless-stopped
environment:
MOODLE_BASE_URL: https://learn.stj.gov
MOODLE_API_TOKEN: ${MOODLE_API_TOKEN}
FOW_BASE_URL: http://fow:3000
FOW_BRIDGE_API_KEY: ${STJ_BRIDGE_API_KEY}
SYNC_INTERVAL_MINUTES: 30
depends_on:
- fow
networks:
- stj-internal
networks:
stj-internal:
driver: bridge
```
---
## 14. Environment Variables Reference
### FOW Application
| Variable | Description | Example |
|---|---|---|
| `NEXTAUTH_URL` | Public URL of the FOW app | `https://jobs.stj.gov` |
| `NEXTAUTH_SECRET` | Random 32-char string for JWT signing | `openssl rand -base64 32` |
| `KEYCLOAK_CLIENT_ID` | Keycloak client ID for FOW | `fow-jobs` |
| `KEYCLOAK_CLIENT_SECRET` | Keycloak client secret | `<from Keycloak admin>` |
| `KEYCLOAK_ISSUER` | Keycloak realm URL | `https://auth.stj.gov/realms/stj` |
| `DATABASE_URL` | PostgreSQL connection string | `postgresql://fow:pass@postgres:5432/fow` |
| `REDIS_URL` | Redis connection URL | `redis://redis:6379` |
| `MINIO_ENDPOINT` | MinIO S3-compatible endpoint | `http://minio:9000` |
| `MINIO_ACCESS_KEY` | MinIO access key | `minioadmin` |
| `MINIO_SECRET_KEY` | MinIO secret key | `<secret>` |
| `MINIO_BUCKET` | Target bucket name | `stj-uploads` |
| `STJ_BRIDGE_API_KEY` | Static bearer token for `/api/skills/sync` | `openssl rand -hex 32` |
| `SMTP_HOST` | MoCTI SMTP server hostname | `smtp.mocti.gov.sl` |
| `SMTP_PORT` | SMTP port | `587` |
| `SMTP_USER` | SMTP username | `noreply@stj.gov` |
| `SMTP_PASS` | SMTP password | `<secret>` |
| `SMTP_FROM` | From address for outbound email | `"STJ Jobs" <noreply@stj.gov>` |
| `NEXT_PUBLIC_APP_URL` | Client-visible app URL | `https://jobs.stj.gov` |
### STJ Bridge Service
| Variable | Description | Example |
|---|---|---|
| `MOODLE_BASE_URL` | Moodle instance root URL | `https://learn.stj.gov` |
| `MOODLE_API_TOKEN` | Moodle web service token | `<from Moodle admin>` |
| `FOW_BASE_URL` | FOW internal URL (Docker network) | `http://fow:3000` |
| `FOW_BRIDGE_API_KEY` | Must match FOW's `STJ_BRIDGE_API_KEY` | Same value as above |
| `SYNC_INTERVAL_MINUTES` | Cron interval in minutes | `30` |
---
## 15. Phase Checklist
### Phase 2 — FOW Job Portal MVP
- [ ] Keycloak OIDC provider configured in `auth.ts`; Credentials and Google providers removed
- [ ] `next-auth.d.ts` updated with `role`, `keycloakId`, `accessToken` on session
- [ ] Test login: youth logs in via Keycloak → session contains `role: "youth"`
- [ ] Test login: employer logs in → redirected to `/employer/jobs` dashboard
- [ ] `middleware.ts` enforces role-based route protection for `/employer/*`, `/admin/*`, `/apply/*`
- [ ] `signIn` callback upserts `User` record on first Keycloak login
- [ ] Prisma schema updated with `JobPosting`, `JobApplication`, `VerifiedSkill`, `JobAlert` models
- [ ] `npx prisma migrate dev --name add_job_portal_models` run successfully
- [ ] `POST /api/jobs` — employer can create job posting (returns 201)
- [ ] `GET /api/jobs` — public browse with keyword/location/skill filters and pagination
- [ ] `GET /api/jobs/[id]` — job detail returns company info and application count
- [ ] `POST /api/jobs/[id]/apply` — youth can apply; duplicate application rejected
- [ ] `GET /api/jobs/[id]/applications` — employer sees applicant list with skill badges
- [ ] `PATCH /api/jobs/[id]/applications/[aid]` — employer updates status
- [ ] `POST /api/jobs/[id]/applications/[aid]/invite` — interview invite email sent
- [ ] `/employer/jobs` page — table with status badges and applicant counts
- [ ] `/employer/jobs/new` page — job posting form with SRD skill taxonomy multi-select
- [ ] `/employer/jobs/[id]/applicants` — Kanban pipeline with drag-and-drop
- [ ] `/employer/jobs/[id]/applicants/[aid]` — full applicant profile with Moodle badges
- [ ] `/jobs` page — listings with filters sidebar
- [ ] `/jobs/[id]` page — job detail with Apply button (role-gated)
- [ ] `/jobs/[id]/apply` page — application form submits correctly
- [ ] `/profile/[userId]` page — shows verified skills section
- [ ] `/applications` page — youth sees status history
- [ ] `GET /api/profile/[userId]/cv` — PDF CV generated with correct Content-Type headers
- [ ] `GET /api/health` — returns `{ status: "ok" }` with 200
- [ ] Vercel dependencies removed; `next.config.js` set to `output: "standalone"`
- [ ] `sharp` installed for image optimization
- [ ] MinIO storage helper working (upload + presigned URL)
- [ ] FOW `Dockerfile` builds successfully (`docker build -t fow .`)
- [ ] FOW container starts and passes health check
### Phase 3 — Skills Integration
- [ ] `POST /api/skills/sync` — rejects requests without valid `STJ_BRIDGE_API_KEY`
- [ ] `POST /api/skills/sync` — upserts `VerifiedSkill` record correctly
- [ ] `POST /api/skills/sync` — returns `404` with `user_not_found` if user hasn't logged in
- [ ] Youth profile (`/profile/[userId]`) displays verified Moodle skill badges
- [ ] `GET /api/jobs/recommendations` — returns scored job list filtered by user's skill categories
- [ ] Job alerts trigger on new skill sync when category matches
- [ ] Job alerts trigger when new `ACTIVE` job posting matches an existing alert
- [ ] Email notifications sent via SMTP on alert match
- [ ] In-app notification badge increments in navbar on new alert
- [ ] STJ Bridge `package.json` and TypeScript files created
- [ ] STJ Bridge `Dockerfile` builds successfully
- [ ] STJ Bridge container starts; logs show first sync attempt
- [ ] End-to-end test: Moodle certificate issued → Bridge picks it up → `VerifiedSkill` created in FOW → Youth profile shows new badge → Matching job appears in recommendations
---
*This document is the authoritative technical reference for the FOW Extended / STJ Job Portal implementation. Update it as architectural decisions evolve.*