---
# System prepended metadata

title: 'FOW Extended — STJ Job Portal: Technical Implementation Plan'

---

# 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.*
