Saidu Bundu-kamara
    • Create new note
    • Create a note from template
      • Sharing URL Link copied
      • /edit
      • View mode
        • Edit mode
        • View mode
        • Book mode
        • Slide mode
        Edit mode View mode Book mode Slide mode
      • Customize slides
      • Note Permission
      • Read
        • Only me
        • Signed-in users
        • Everyone
        Only me Signed-in users Everyone
      • Write
        • Only me
        • Signed-in users
        • Everyone
        Only me Signed-in users Everyone
    • Invite by email
      Invitee

      This note has no invitees

    • Publish Note

      Share your work with the world Congratulations! 🎉 Your note is out in the world Publish Note No publishing access yet

      Your note will be visible on your profile and discoverable by anyone.
      Your note is now live.
      This note is visible on your profile and discoverable online.
      Everyone on the web can find and read all notes of this public team.

      Your account was recently created. Publishing will be available soon, allowing you to share notes on your public page and in search results.

      Your team account was recently created. Publishing will be available soon, allowing you to share notes on your public page and in search results.

      Explore these features while you wait
      Complete general settings
      Bookmark and like published notes
      Write a few more notes
      Complete general settings
      Write a few more notes
      See published notes
      Unpublish note
      Please check the box to agree to the Community Guidelines.
      View profile
    • Commenting
      Permission
      Disabled Forbidden Owners Signed-in users Everyone
    • Enable
    • Permission
      • Forbidden
      • Owners
      • Signed-in users
      • Everyone
    • Suggest edit
      Permission
      Disabled Forbidden Owners Signed-in users Everyone
    • Enable
    • Permission
      • Forbidden
      • Owners
      • Signed-in users
    • Emoji Reply
    • Enable
    • Versions and GitHub Sync
    • Note settings
    • Note Insights New
    • Make a copy
    • Transfer ownership
    • Delete this note
    • Save as template
    • Insert from template
    • Import from
      • Dropbox
      • Google Drive
      • Gist
      • Clipboard
    • Export to
      • Dropbox
      • Google Drive
      • Gist
    • Download
      • Markdown
      • HTML
      • Raw HTML
Menu Note settings Note Insights Versions and GitHub Sync Sharing URL Create Help
Create Create new note Create a note from template
Menu
Options
Make a copy Transfer ownership Delete this note
Import from
Dropbox Google Drive Gist Clipboard
Export to
Dropbox Google Drive Gist
Download
Markdown HTML Raw HTML
Back
Sharing URL Link copied
/edit
View mode
  • Edit mode
  • View mode
  • Book mode
  • Slide mode
Edit mode View mode Book mode Slide mode
Customize slides
Note Permission
Read
Only me
  • Only me
  • Signed-in users
  • Everyone
Only me Signed-in users Everyone
Write
Only me
  • Only me
  • Signed-in users
  • Everyone
Only me Signed-in users Everyone
  • Invite by email
    Invitee

    This note has no invitees

  • Publish Note

    Share your work with the world Congratulations! 🎉 Your note is out in the world Publish Note No publishing access yet

    Your note will be visible on your profile and discoverable by anyone.
    Your note is now live.
    This note is visible on your profile and discoverable online.
    Everyone on the web can find and read all notes of this public team.

    Your account was recently created. Publishing will be available soon, allowing you to share notes on your public page and in search results.

    Your team account was recently created. Publishing will be available soon, allowing you to share notes on your public page and in search results.

    Explore these features while you wait
    Complete general settings
    Bookmark and like published notes
    Write a few more notes
    Complete general settings
    Write a few more notes
    See published notes
    Unpublish note
    Please check the box to agree to the Community Guidelines.
    View profile
    Engagement control
    Commenting
    Permission
    Disabled Forbidden Owners Signed-in users Everyone
    Enable
    Permission
    • Forbidden
    • Owners
    • Signed-in users
    • Everyone
    Suggest edit
    Permission
    Disabled Forbidden Owners Signed-in users Everyone
    Enable
    Permission
    • Forbidden
    • Owners
    • Signed-in users
    Emoji Reply
    Enable
    Import from Dropbox Google Drive Gist Clipboard
       Owned this note    Owned this note      
    Published Linked with GitHub
    • Any changes
      Be notified of any changes
    • Mention me
      Be notified of mention me
    • Unsubscribe
    # 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.*

    Import from clipboard

    Paste your markdown or webpage here...

    Advanced permission required

    Your current role can only read. Ask the system administrator to acquire write and comment permission.

    This team is disabled

    Sorry, this team is disabled. You can't edit this note.

    This note is locked

    Sorry, only owner can edit this note.

    Reach the limit

    Sorry, you've reached the max length this note can be.
    Please reduce the content or divide it to more notes, thank you!

    Import from Gist

    Import from Snippet

    or

    Export to Snippet

    Are you sure?

    Do you really want to delete this note?
    All users will lose their connection.

    Create a note from template

    Create a note from template

    Oops...
    This template has been removed or transferred.
    Upgrade
    All
    • All
    • Team
    No template.

    Create a template

    Upgrade

    Delete template

    Do you really want to delete this template?
    Turn this template into a regular note and keep its content, versions, and comments.

    This page need refresh

    You have an incompatible client version.
    Refresh to update.
    New version available!
    See releases notes here
    Refresh to enjoy new features.
    Your user state has changed.
    Refresh to load new user state.

    Sign in

    Forgot password
    or
    Sign in via Google Sign in via Facebook Sign in via X(Twitter) Sign in via GitHub Sign in via Dropbox Sign in with Wallet
    Wallet ( )
    Connect another wallet

    New to HackMD? Sign up

    By signing in, you agree to our terms of service.

    Help

    • English
    • 中文
    • Français
    • Deutsch
    • 日本語
    • Español
    • Català
    • Ελληνικά
    • Português
    • italiano
    • Türkçe
    • Русский
    • Nederlands
    • hrvatski jezik
    • język polski
    • Українська
    • हिन्दी
    • svenska
    • Esperanto
    • dansk

    Documents

    Help & Tutorial

    How to use Book mode

    Slide Example

    API Docs

    Edit in VSCode

    Install browser extension

    Contacts

    Feedback

    Discord

    Send us email

    Resources

    Releases

    Pricing

    Blog

    Policy

    Terms

    Privacy

    Cheatsheet

    Syntax Example Reference
    # Header Header 基本排版
    - Unordered List
    • Unordered List
    1. Ordered List
    1. Ordered List
    - [ ] Todo List
    • Todo List
    > Blockquote
    Blockquote
    **Bold font** Bold font
    *Italics font* Italics font
    ~~Strikethrough~~ Strikethrough
    19^th^ 19th
    H~2~O H2O
    ++Inserted text++ Inserted text
    ==Marked text== Marked text
    [link text](https:// "title") Link
    ![image alt](https:// "title") Image
    `Code` Code 在筆記中貼入程式碼
    ```javascript
    var i = 0;
    ```
    var i = 0;
    :smile: :smile: Emoji list
    {%youtube youtube_id %} Externals
    $L^aT_eX$ LaTeX
    :::info
    This is a alert area.
    :::

    This is a alert area.

    Versions and GitHub Sync
    Get Full History Access

    • Edit version name
    • Delete

    revision author avatar     named on  

    More Less

    Note content is identical to the latest version.
    Compare
      Choose a version
      No search result
      Version not found
    Sign in to link this note to GitHub
    Learn more
    This note is not linked with GitHub
     

    Feedback

    Submission failed, please try again

    Thanks for your support.

    On a scale of 0-10, how likely is it that you would recommend HackMD to your friends, family or business associates?

    Please give us some advice and help us improve HackMD.

     

    Thanks for your feedback

    Remove version name

    Do you want to remove this version name and description?

    Transfer ownership

    Transfer to
      Warning: is a public team. If you transfer note to this team, everyone on the web can find and read this note.

        Link with GitHub

        Please authorize HackMD on GitHub
        • Please sign in to GitHub and install the HackMD app on your GitHub repo.
        • HackMD links with GitHub through a GitHub App. You can choose which repo to install our App.
        Learn more  Sign in to GitHub

        Push the note to GitHub Push to GitHub Pull a file from GitHub

          Authorize again
         

        Choose which file to push to

        Select repo
        Refresh Authorize more repos
        Select branch
        Select file
        Select branch
        Choose version(s) to push
        • Save a new version and push
        • Choose from existing versions
        Include title and tags
        Available push count

        Pull from GitHub

         
        File from GitHub
        File from HackMD

        GitHub Link Settings

        File linked

        Linked by
        File path
        Last synced branch
        Available push count

        Danger Zone

        Unlink
        You will no longer receive notification when GitHub file changes after unlink.

        Syncing

        Push failed

        Push successfully