content: rewrite JobTrack and InboxIntel case studies from current codebases

Re-audited both apps (full codebase + docs) and rewrote both case studies to a
professional-SaaS structure: Solution and Feature Showcase are new sections
(schema + FeatureList component), Engineering Highlights/Challenges reframe the
existing architecture/decisions content around real, verified engineering work
(deterministic CV match, multi-provider email abstraction, SSRF-hardened
fetcher, hybrid RRF search, preview-then-confirm cleanup), and Status & roadmap
now separates Implemented from Planned explicitly. InboxIntel copy is careful
to state it's Gmail-only today with multi-provider fully designed but not built.
This commit is contained in:
cesnimda
2026-07-12 20:12:32 +02:00
parent 29ecfc0e79
commit 0d45b32bd5
5 changed files with 454 additions and 107 deletions
@@ -0,0 +1,28 @@
---
interface Feature {
title: string;
value: string;
technical: string;
}
interface Props {
features: Feature[];
}
const { features } = Astro.props;
---
<ol class="flex flex-col gap-5">
{
features.map((f, i) => (
<li class="border-line bg-surface-1 rounded-md border p-5">
<p class="flex gap-3">
<span class="text-body text-accent font-mono font-semibold">
{String(i + 1).padStart(2, '0')}
</span>
<span class="text-ink font-semibold">{f.title}</span>
</p>
<p class="text-body text-ink-muted mt-2 pl-8">{f.value}</p>
<p class="text-small text-ink-faint mt-2 border-line border-l pl-3 ml-8">{f.technical}</p>
</li>
))
}
</ol>
@@ -2,6 +2,7 @@
import type { Locale } from '@i18n/locales'; import type { Locale } from '@i18n/locales';
import ArchDiagram from '@components/case-study/ArchDiagram.astro'; import ArchDiagram from '@components/case-study/ArchDiagram.astro';
import DecisionList from '@components/case-study/DecisionList.astro'; import DecisionList from '@components/case-study/DecisionList.astro';
import FeatureList from '@components/case-study/FeatureList.astro';
import Gallery from '@components/case-study/Gallery.astro'; import Gallery from '@components/case-study/Gallery.astro';
interface Block { interface Block {
@@ -15,6 +16,7 @@ interface Section {
anchorId: string; anchorId: string;
blocks?: Block[]; blocks?: Block[];
decisions?: { n: number; choice: string; alternative: string; rationale: string }[]; decisions?: { n: number; choice: string; alternative: string; rationale: string }[];
features?: { title: string; value: string; technical: string }[];
} }
interface Media { interface Media {
src: string; src: string;
@@ -91,6 +93,14 @@ const galleryMedia = media.slice(1); // media[0] is the hero shown above the art
) )
} }
{
section.kind === 'features' && section.features && (
<div class="mt-6">
<FeatureList features={section.features} />
</div>
)
}
{ {
section.kind === 'screenshots' && section.kind === 'screenshots' &&
(galleryMedia.length > 0 ? ( (galleryMedia.length > 0 ? (
+200 -48
View File
@@ -1,6 +1,6 @@
import type { Project } from '@lib/schema'; import type { Project } from '@lib/schema';
/* InboxIntel case study. Source: F:\Documents\InboxIntel\InboxIntel + current app mockups. */ /* InboxIntel case study. Source: F:\Documents\InboxIntel\InboxIntel (full codebase + docs audit, 2026-07-12). */
export const inboxintel: Project = { export const inboxintel: Project = {
id: 'inboxintel', id: 'inboxintel',
name: 'InboxIntel', name: 'InboxIntel',
@@ -9,7 +9,7 @@ export const inboxintel: Project = {
template: 'case-study', template: 'case-study',
stack: [ stack: [
{ name: '.NET 10', context: { en: 'ASP.NET Core', no: 'ASP.NET Core' } }, { name: '.NET 10', context: { en: 'ASP.NET Core', no: 'ASP.NET Core' } },
{ name: 'React', context: { en: 'TypeScript SPA', no: 'TypeScript-SPA' } }, { name: 'React', context: { en: 'Vite · TypeScript', no: 'Vite · TypeScript' } },
{ name: 'PostgreSQL', context: { en: 'EF Core · pgvector', no: 'EF Core · pgvector' } }, { name: 'PostgreSQL', context: { en: 'EF Core · pgvector', no: 'EF Core · pgvector' } },
{ name: 'Ollama', context: { en: 'embeddings · summaries', no: 'embeddings · sammendrag' } }, { name: 'Ollama', context: { en: 'embeddings · summaries', no: 'embeddings · sammendrag' } },
{ name: 'Clean Architecture' }, { name: 'Clean Architecture' },
@@ -20,8 +20,8 @@ export const inboxintel: Project = {
viewBox: '0 0 1120 300', viewBox: '0 0 1120 300',
title: { en: 'InboxIntel architecture', no: 'InboxIntel-arkitektur' }, title: { en: 'InboxIntel architecture', no: 'InboxIntel-arkitektur' },
desc: { desc: {
en: 'A React SPA calls an ASP.NET Core API layered as Clean Architecture. A hosted background worker syncs the external Gmail API into PostgreSQL with pgvector, and a local Ollama model produces the embeddings and summaries that power semantic search.', en: 'A React SPA calls an ASP.NET Core API layered as Clean Architecture. A hosted background worker syncs the Gmail API into PostgreSQL with pgvector, and a local Ollama model produces the embeddings and summaries that power hybrid search.',
no: 'En React-app kaller et ASP.NET Core-API bygget som Clean Architecture. En bakgrunnstjeneste synkroniserer det eksterne Gmail-API-et inn i PostgreSQL med pgvector, og en lokal Ollama-modell lager embeddings og sammendrag som driver semantisk søk.', no: 'En React-app kaller et ASP.NET Core-API bygget som Clean Architecture. En bakgrunnstjeneste synkroniserer Gmail-API-et inn i PostgreSQL med pgvector, og en lokal Ollama-modell lager embeddings og sammendrag som driver hybrid søk.',
}, },
nodes: [ nodes: [
{ {
@@ -138,11 +138,11 @@ export const inboxintel: Project = {
content: { content: {
en: { en: {
valueProp: valueProp:
'A self-hosted inbox intelligence and cleanup tool — semantic search, AI summaries and inbox-health analytics over your mail.', 'A self-hosted inbox intelligence and cleanup tool — hybrid search, AI summaries and inbox-health analytics over your mail.',
cardTeaser: cardTeaser:
'Self-hosted inbox intelligence — hybrid semantic + full-text search, AI summaries and inbox-health analytics, with safe cleanup by design.', 'Self-hosted inbox intelligence — hybrid semantic + full-text search, AI summaries and inbox-health analytics, with safe cleanup by design.',
tldr: { tldr: {
what: 'Inbox intelligence: split-view reading, semantic search, AI summaries, inbox-health analytics and safe cleanup.', what: 'Inbox intelligence: split-view reading, hybrid semantic search, AI summaries, inbox-health analytics and safe cleanup.',
why: 'To practise Clean Architecture properly, and make search useful and destructive operations safe by design.', why: 'To practise Clean Architecture properly, and make search useful and destructive operations safe by design.',
stack: '.NET 10 · PostgreSQL + pgvector · React · Ollama · Docker · Clean Architecture.', stack: '.NET 10 · PostgreSQL + pgvector · React · Ollama · Docker · Clean Architecture.',
role: 'Sole architect and developer, front to back.', role: 'Sole architect and developer, front to back.',
@@ -155,55 +155,107 @@ export const inboxintel: Project = {
blocks: [ blocks: [
{ {
type: 'p', type: 'p',
text: 'A busy inbox is hard to search and dangerous to clean up — keyword search misses what you meant, and one wrong bulk filter deletes things you cant get back. I built InboxIntel to make an inbox actually searchable (by meaning, not just words), understandable at a glance, and safe to tidy — all self-hosted, with a clean, testable backend.', text: 'A busy inbox is hard to search and dangerous to clean up — keyword search misses what you meant, and one wrong bulk filter deletes things you can\'t get back. I built InboxIntel to make an inbox actually searchable (by meaning, not just words), understandable at a glance, and safe to tidy — self-hosted, with a clean, testable backend built to production discipline rather than side-project shortcuts.',
}, },
], ],
}, },
{ {
kind: 'architecture', kind: 'solution',
heading: 'Architecture', heading: 'Solution',
anchorId: 'architecture', anchorId: 'solution',
blocks: [ blocks: [
{ {
type: 'p', type: 'p',
text: 'A React SPA over an ASP.NET Core API built as Clean Architecture — the dependency rule points inward (Api → Infrastructure → Application → Domain), and controllers hold no business logic. A hosted background worker syncs Gmail into PostgreSQL; a local Ollama model generates embeddings (stored with pgvector) and message summaries, which power hybrid semantic + full-text search.', text: 'InboxIntel connects to your mailbox via OAuth, syncs it into a local PostgreSQL database, and layers three things on top: a split-view reading pane for fast triage, hybrid search that finds mail by meaning as well as keywords, and an analytics dashboard that shows inbox health at a glance. Every destructive action — bulk trash, unsubscribe — previews before it executes.',
}, },
{ {
type: 'ul', type: 'ul',
items: [ items: [
'Reading: a persistent list beside a resizable pane with an AI summary — triage without opening a new tab.', 'Reading: a persistent list beside a resizable pane with an AI summary — triage without opening a new tab.',
'Search: semantic (pgvector) + full-text, with why-matched highlights and a relevance score.', 'Search: hybrid semantic (pgvector) + full-text, with a fuzzy fallback for typos and a relevance score.',
'Analytics: an inbox-health grade, emails by category, a 90-day volume trend and top senders.', 'Analytics: an inbox-health grade, emails by category, a 90-day volume trend and top senders.',
'Cleanup: preview-then-confirm on every destructive action; the AI is advisory only.', 'Cleanup: preview-then-confirm on every destructive action; the AI is advisory only.',
], ],
}, },
{
type: 'p',
text: 'Today it connects to Gmail only. A full multi-provider architecture — a provider abstraction, unified email model, and an Outlook/IMAP rollout plan — is designed in detail (a dozen internal design documents covering auth, data model, admin platform and migration path); building it out is the next major milestone rather than a shipped feature.',
},
],
},
{
kind: 'features',
heading: 'Feature showcase',
anchorId: 'features',
features: [
{
title: 'Hybrid search that understands meaning, not just words',
value:
'Find "that gym receipt" even if you never typed the word "gym" in the email — search stops being a literal keyword match.',
technical:
'Reciprocal Rank Fusion combines Postgres full-text search (tsvector/GIN, websearch_to_tsquery) with pgvector cosine-similarity search over local Ollama embeddings, fusing the top 50 results from each. A pg_trgm fuzzy fallback with a hand-tuned 0.3 similarity threshold (the default 0.6 misses real typos like "recieved") catches what neither ranked list does. Any AI-path failure — Ollama down, embeddings not yet backfilled — degrades silently to lexical-only search.',
},
{
title: 'Cleanup you can trust',
value:
'Preview exactly what a bulk-delete or unsubscribe action will do before anything happens — no accidental data loss from an over-broad filter.',
technical:
'Every destructive action requires a server-side Preview call before Execute, with an explicit Confirmed flag checked twice — once by FluentValidation, once inside the service itself as defence in depth. Unsubscribe targets are validated by an SSRF egress guard against private/metadata IP ranges before any outbound request is made, and mailto: links are surfaced to the user rather than auto-sent.',
},
{
title: 'Inbox health at a glance',
value:
'A draggable dashboard shows volume trends, top senders and category breakdowns without digging through folders manually.',
technical:
'A react-grid-layout dashboard with per-user persisted widget positions, backed by dedicated analytics endpoints (health grade, category heatmap, 90-day volume, top senders, attachment stats) computed server-side rather than client-aggregated.',
},
],
},
{
kind: 'architecture',
heading: 'Engineering highlights',
anchorId: 'architecture',
blocks: [
{
type: 'p',
text: 'The backend is Clean Architecture with the dependency rule enforced end to end: Api → Infrastructure → Application → Domain, controllers hold no business logic, and the Domain project has zero external dependencies bar the one type needed for full-text search. AI is fully swappable at the DI boundary — Null, Ollama and OpenAI implementations of the same IAiProvider interface — so the rest of the app never branches on whether AI is enabled.',
},
{
type: 'ul',
items: [
'Sync engine: checkpoints its page token and message count after every page, so a killed sync resumes rather than restarting; incremental syncs use Gmail\'s historyId watermark instead of re-scanning; all calls run through Polly retry/backoff.',
'Pagination: keyset (cursor) pagination on large result sets — O(page size) instead of OFFSET\'s O(page × page size) — alongside plain offset pagination for the general browse path.',
'AI is advisory only by contract: every AiService method carries the explicit guarantee that it never archives, deletes, labels or unsubscribes on its own.',
'Security is self-audited on a paper trail: a dated, severity-coded internal audit (plaintext-storage risk, missing rate limits, unencrypted keys) with each finding resolved via its own tracked PR and new tests, not just prose claims.',
],
},
], ],
}, },
{ {
kind: 'decisions', kind: 'decisions',
heading: 'Key decisions & trade-offs', heading: 'Challenges',
anchorId: 'decisions', anchorId: 'decisions',
decisions: [ decisions: [
{ {
n: 1, n: 1,
choice: 'Local Ollama embeddings + pgvector for semantic search.', choice: 'Fuse lexical and semantic search with Reciprocal Rank Fusion, degrading silently on failure.',
alternative: 'A hosted embeddings API and a vector database.', alternative: 'Run semantic search as a separate mode the user has to switch to.',
rationale: rationale:
'Mail is private, so embeddings stay on my own hardware and live right next to the data in Postgres via pgvector — one datastore, no third party, no per-call cost. The trade-off is running the model myself.', 'Keyword search alone misses paraphrased or vaguely-remembered mail; semantic search alone drops exact-match precision. RRF blends the top 50 of each into one ranked list automatically, and if Ollama is down or an email hasn\'t been embedded yet, the query still returns lexical results instead of erroring. Outcome: one search box that works whether or not the AI layer is healthy.',
}, },
{ {
n: 2, n: 2,
choice: 'Encrypt OAuth refresh tokens at rest and never log them.', choice: 'Encrypt OAuth refresh tokens at rest and never log them.',
alternative: 'Store them as plain columns.', alternative: 'Store them as plain columns — simpler, faster to ship.',
rationale: rationale:
'Refresh tokens are long-lived keys to someones mailbox. Theyre encrypted with the ASP.NET Core Data Protection API (AES), with keys persisted to a mounted volume — the single most important security decision in the app.', 'Refresh tokens are long-lived keys to someone\'s mailbox. They\'re encrypted with the ASP.NET Core Data Protection API (AES), with keys persisted to a mounted volume — flagged in the internal audit as the single most important security decision in the app, and verified clean on re-review.',
}, },
{ {
n: 3, n: 3,
choice: 'Every destructive action is preview-then-confirm.', choice: 'Require a mandatory preview before any destructive action, enforced twice.',
alternative: 'Delete immediately on request.', alternative: 'Trust client-side confirmation and execute on request.',
rationale: rationale:
'All cleanup and unsubscribe actions require a server-side preview and an explicit Confirmed flag. The AI layer is advisory only and can never trigger a deletion.', 'An audit finding showed rate limiting and validation gaps around bulk actions early on. The fix — preview-then-confirm checked at both the validator and the service layer, plus an SSRF guard on every outbound unsubscribe request — closed the finding and shipped with regression tests proving the 400 on invalid input.',
}, },
], ],
}, },
@@ -216,9 +268,9 @@ export const inboxintel: Project = {
type: 'ul', type: 'ul',
items: [ items: [
'Google-OAuth gated; Gmail scopes are read/modify only — no send scope is ever requested.', 'Google-OAuth gated; Gmail scopes are read/modify only — no send scope is ever requested.',
'Embeddings and summaries are generated locally (Ollama); mail never leaves the box for a third-party model.', 'Embeddings and summaries are generated locally (Ollama) by default; an optional cloud fallback exists but mail never leaves the box unless that\'s explicitly enabled.',
'Polly retry/backoff against Gmail rate limits; Serilog structured logging that never records tokens.', 'Polly retry/backoff against Gmail rate limits; Serilog structured logging that never records tokens; OpenTelemetry instrumentation wired for production observability.',
'Clean Architecture keeps the layers testable; integration tests boot the API host and assert authorization.', 'Clean Architecture keeps the layers testable; 39+ unit and integration tests, including live-database tests for the full-text/fuzzy/pgvector paths.',
], ],
}, },
], ],
@@ -230,12 +282,36 @@ export const inboxintel: Project = {
}, },
{ {
kind: 'next', kind: 'next',
heading: 'Status & whats next', heading: 'Status & roadmap',
anchorId: 'next', anchorId: 'next',
blocks: [ blocks: [
{ {
type: 'p', type: 'p',
text: 'In active development. Search, summaries and analytics work end to end; next up is tuning the semantic-recall thresholds, expanding the integration-test suite, and working toward connectors beyond Gmail so it becomes a genuinely universal inbox tool.', text: 'Implemented today:',
},
{
type: 'ul',
items: [
'Gmail OAuth sync into PostgreSQL, with resumable, checkpointed background sync',
'Hybrid semantic + full-text + fuzzy search with keyset pagination',
'Draggable analytics dashboard: inbox health, categories, volume, top senders',
'Safe bulk cleanup and unsubscribe (preview-then-confirm, SSRF-guarded)',
'Optional local AI (Ollama) or cloud fallback for classification, summaries and natural-language search',
'A self-audited, remediated security posture with tracked findings and fixes',
],
},
{
type: 'p',
text: 'Designed but not yet built — the next major milestones:',
},
{
type: 'ul',
items: [
'Multi-provider support (Outlook, generic IMAP) — architecture and migration plan already written',
'Rules engine for automated cleanup, sender policy (block/whitelist/screener) and an activity log with undo',
'A privacy monitor (breach-check against known leak databases)',
'Tuned AI prompts and richer HTML-body/attachment parsing beyond the common cases',
],
}, },
], ],
}, },
@@ -243,11 +319,11 @@ export const inboxintel: Project = {
}, },
no: { no: {
valueProp: valueProp:
'Et egendriftet verktøy for innboks-intelligens og opprydding — semantisk søk, AI-sammendrag og innboks-helse over e-posten din.', 'Et egendriftet verktøy for innboks-intelligens og opprydding — hybrid søk, AI-sammendrag og innboks-helse over e-posten din.',
cardTeaser: cardTeaser:
'Egendriftet innboks-intelligens — hybrid semantisk + fulltekst-søk, AI-sammendrag og innboks-helse, med trygg opprydding fra bunnen.', 'Egendriftet innboks-intelligens — hybrid semantisk + fulltekst-søk, AI-sammendrag og innboks-helse, med trygg opprydding fra bunnen.',
tldr: { tldr: {
what: 'Innboks-intelligens: delt visning, semantisk søk, AI-sammendrag, innboks-helse og trygg opprydding.', what: 'Innboks-intelligens: delt visning, hybrid semantisk søk, AI-sammendrag, innboks-helse og trygg opprydding.',
why: 'For å øve på Clean Architecture skikkelig, gjøre søk nyttig og destruktive operasjoner trygge fra bunnen.', why: 'For å øve på Clean Architecture skikkelig, gjøre søk nyttig og destruktive operasjoner trygge fra bunnen.',
stack: '.NET 10 · PostgreSQL + pgvector · React · Ollama · Docker · Clean Architecture.', stack: '.NET 10 · PostgreSQL + pgvector · React · Ollama · Docker · Clean Architecture.',
role: 'Eneste arkitekt og utvikler, fra ende til ende.', role: 'Eneste arkitekt og utvikler, fra ende til ende.',
@@ -260,55 +336,107 @@ export const inboxintel: Project = {
blocks: [ blocks: [
{ {
type: 'p', type: 'p',
text: 'En travel innboks er vanskelig å søke i og farlig å rydde i — nøkkelordsøk bommer på det du mente, og ett feil massefilter sletter ting du ikke får tilbake. Jeg bygde InboxIntel for å gjøre en innboks faktisk søkbar (på mening, ikke bare ord), forståelig med ett blikk, og trygg å rydde i — alt egendriftet, med en ryddig og testbar backend.', text: 'En travel innboks er vanskelig å søke i og farlig å rydde i — nøkkelordsøk bommer på det du mente, og ett feil massefilter sletter ting du ikke får tilbake. Jeg bygde InboxIntel for å gjøre en innboks faktisk søkbar (på mening, ikke bare ord), forståelig med ett blikk, og trygg å rydde i — egendriftet, med en ryddig og testbar backend bygget til produksjonsstandard, ikke fritidsprosjekt-snarveier.',
}, },
], ],
}, },
{ {
kind: 'architecture', kind: 'solution',
heading: 'Arkitektur', heading: 'Løsning',
anchorId: 'arkitektur', anchorId: 'losning',
blocks: [ blocks: [
{ {
type: 'p', type: 'p',
text: 'En React-app over et ASP.NET Core-API bygget som Clean Architecture — avhengighetsregelen peker innover (Api → Infrastructure → Application → Domain), og kontrollerne har ingen forretningslogikk. En bakgrunnstjeneste synkroniserer Gmail inn i PostgreSQL; en lokal Ollama-modell lager embeddings (lagret med pgvector) og sammendrag, som driver hybrid semantisk + fulltekst-søk.', text: 'InboxIntel kobler seg til postkassen din via OAuth, synkroniserer den inn i en lokal PostgreSQL-database, og legger tre ting på toppen: en delt lesevisning for rask sortering, hybrid søk som finner e-post på mening i tillegg til nøkkelord, og et analysedashbord som viser innboks-helse med ett blikk. Hver destruktive handling — massesletting, avmelding — forhåndsvises før den utføres.',
}, },
{ {
type: 'ul', type: 'ul',
items: [ items: [
'Lesing: en fast liste ved siden av en justerbar rute med AI-sammendrag — sorter uten å åpne en ny fane.', 'Lesing: en fast liste ved siden av en justerbar rute med AI-sammendrag — sorter uten å åpne en ny fane.',
'Søk: semantisk (pgvector) + fulltekst, med treff-forklaring og relevans-score.', 'Søk: hybrid semantisk (pgvector) + fulltekst, med fuzzy-fallback for skrivefeil og relevans-score.',
'Analyse: en innboks-helsekarakter, e-post per kategori, 90-dagers volumtrend og toppavsendere.', 'Analyse: en innboks-helsekarakter, e-post per kategori, 90-dagers volumtrend og toppavsendere.',
'Opprydding: forhåndsvis-så-bekreft på hver destruktive handling; AI-en er kun rådgivende.', 'Opprydding: forhåndsvis-så-bekreft på hver destruktive handling; AI-en er kun rådgivende.',
], ],
}, },
{
type: 'p',
text: 'I dag kobles den kun til Gmail. En full arkitektur for flere leverandører — en leverandørabstraksjon, en samlet e-postmodell og en utrullingsplan for Outlook/IMAP — er designet i detalj (et dusin interne designdokumenter om autentisering, datamodell, admin-plattform og migreringsvei); å bygge det ut er neste store milepæl, ikke en levert funksjon.',
},
],
},
{
kind: 'features',
heading: 'Utvalgte funksjoner',
anchorId: 'funksjoner',
features: [
{
title: 'Hybrid søk som forstår mening, ikke bare ord',
value:
'Finn «den kvittering fra treningssenteret» selv om du aldri skrev ordet «trening» i e-posten — søk slutter å være et bokstavelig nøkkelordtreff.',
technical:
'Reciprocal Rank Fusion kombinerer Postgres fulltekstsøk (tsvector/GIN, websearch_to_tsquery) med pgvector kosinuslikhet over lokale Ollama-embeddings, og fusjonerer de 50 beste resultatene fra hver. En pg_trgm fuzzy-fallback med en håndjustert similaritetsterskel på 0,3 (standarden 0,6 bommer på ekte skrivefeil som «mottat»→«mottatt») fanger opp det ingen av de rangerte listene gjør. Enhver feil i AI-stien — Ollama nede, embeddings ikke bakfylt ennå — degraderer stille til kun leksikalsk søk.',
},
{
title: 'Opprydding du kan stole på',
value:
'Forhåndsvis nøyaktig hva en massesletting eller avmelding vil gjøre før noe skjer — ingen utilsiktet datatap fra et for bredt filter.',
technical:
'Hver destruktive handling krever et server-side Preview-kall før Execute, med et eksplisitt Confirmed-flagg sjekket to ganger — én gang av FluentValidation, én gang inne i selve tjenesten som ekstra sikkerhetslag. Avmeldingsmål valideres av en SSRF-utgangsvakt mot private/metadata-IP-områder før noen utgående forespørsel gjøres, og mailto-lenker vises til brukeren i stedet for å sendes automatisk.',
},
{
title: 'Innboks-helse med ett blikk',
value:
'Et flyttbart dashbord viser volumtrender, toppavsendere og kategorifordeling uten å måtte grave gjennom mapper manuelt.',
technical:
'Et react-grid-layout-dashbord med per-bruker lagret widget-plassering, støttet av dedikerte analyse-endepunkter (helsekarakter, kategori-varmekart, 90-dagers volum, toppavsendere, vedleggsstatistikk) beregnet server-side i stedet for aggregert på klienten.',
},
],
},
{
kind: 'architecture',
heading: 'Tekniske høydepunkter',
anchorId: 'arkitektur',
blocks: [
{
type: 'p',
text: 'Backend er Clean Architecture med avhengighetsregelen håndhevet fra ende til ende: Api → Infrastructure → Application → Domain, kontrollerne har ingen forretningslogikk, og Domain-prosjektet har ingen eksterne avhengigheter bortsett fra én type som trengs for fulltekstsøk. AI er fullt utskiftbart ved DI-grensen — Null-, Ollama- og OpenAI-implementasjoner av samme IAiProvider-grensesnitt — så resten av appen forgrener seg aldri på om AI er skrudd på.',
},
{
type: 'ul',
items: [
'Synk-motor: sjekkpunkter side-token og meldingstall etter hver side, så en avbrutt synk fortsetter i stedet for å starte på nytt; inkrementelle synker bruker Gmails historyId-vannmerke i stedet for å skanne på nytt; alle kall går gjennom Polly retry/backoff.',
'Paginering: keyset (markør)-paginering på store resultatsett — O(sidestørrelse) i stedet for OFFSETs O(side × sidestørrelse) — ved siden av vanlig offset-paginering for generell nettlesing.',
'AI er kun rådgivende ved kontrakt: hver AiService-metode bærer den eksplisitte garantien at den aldri arkiverer, sletter, merker eller melder av på egen hånd.',
'Sikkerhet er egen-revidert med papirspor: en datert, alvorlighetskodet intern revisjon (risiko for klartekst-lagring, manglende ratebegrensning, ukrypterte nøkler) hvor hvert funn er løst via egen sporet PR og nye tester, ikke bare prosapåstander.',
],
},
], ],
}, },
{ {
kind: 'decisions', kind: 'decisions',
heading: 'Viktige valg og avveininger', heading: 'Utfordringer',
anchorId: 'beslutninger', anchorId: 'utfordringer',
decisions: [ decisions: [
{ {
n: 1, n: 1,
choice: 'Lokale Ollama-embeddings + pgvector for semantisk søk.', choice: 'Fusjonere leksikalsk og semantisk søk med Reciprocal Rank Fusion, med stille degradering ved feil.',
alternative: 'Et hostet embeddings-API og en egen vektordatabase.', alternative: 'Kjøre semantisk søk som en egen modus brukeren må bytte til.',
rationale: rationale:
'E-post er privat, så embeddings blir på min egen maskin og ligger rett ved siden av dataene i Postgres via pgvector — én datalagring, ingen tredjepart, ingen kostnad per kall. Avveiningen er å drifte modellen selv.', 'Nøkkelordsøk alene bommer på omskrevet eller vagt husket e-post; semantisk søk alene mister presisjon på eksakte treff. RRF blander de 50 beste fra hver til én rangert liste automatisk, og hvis Ollama er nede eller en e-post ikke er bakfylt ennå, returnerer spørringen fortsatt leksikalske resultater i stedet for å feile. Resultat: én søkeboks som fungerer uansett om AI-laget er friskt.',
}, },
{ {
n: 2, n: 2,
choice: 'Kryptere OAuth-refresh-tokens i ro og aldri logge dem.', choice: 'Kryptere OAuth-refresh-tokens i ro og aldri logge dem.',
alternative: 'Lagre dem som vanlige kolonner.', alternative: 'Lagre dem som vanlige kolonner — enklere, raskere å levere.',
rationale: rationale:
'Refresh-tokens er langlevde nøkler til noens innboks. De krypteres med ASP.NET Core Data Protection API (AES), med nøkler lagret på et montert volum — det viktigste sikkerhetsvalget i appen.', 'Refresh-tokens er langlevde nøkler til noens innboks. De krypteres med ASP.NET Core Data Protection API (AES), med nøkler lagret på et montert volum — flagget i den interne revisjonen som det viktigste sikkerhetsvalget i appen, og verifisert rent ved ny gjennomgang.',
}, },
{ {
n: 3, n: 3,
choice: 'Alle destruktive handlinger er forhåndsvis-så-bekreft.', choice: 'Kreve obligatorisk forhåndsvisning før enhver destruktiv handling, håndhevet to ganger.',
alternative: 'Slette umiddelbart ved forespørsel.', alternative: 'Stole på klientsidebekreftelse og utføre ved forespørsel.',
rationale: rationale:
'All opprydding og avmelding krever en server-side forhåndsvisning og et eksplisitt Confirmed-flagg. AI-laget er kun rådgivende og kan aldri utløse en sletting.', 'Et revisjonsfunn viste ratebegrensnings- og valideringshull rundt massehandlinger tidlig. Løsningen — forhåndsvis-så-bekreft sjekket både på validator- og tjenestenivå, pluss en SSRF-vakt på hver utgående avmeldingsforespørsel — lukket funnet og ble levert med regresjonstester som beviser 400 ved ugyldig input.',
}, },
], ],
}, },
@@ -321,9 +449,9 @@ export const inboxintel: Project = {
type: 'ul', type: 'ul',
items: [ items: [
'Google-OAuth-beskyttet; Gmail-scopes er kun lese/endre — send-scope blir aldri etterspurt.', 'Google-OAuth-beskyttet; Gmail-scopes er kun lese/endre — send-scope blir aldri etterspurt.',
'Embeddings og sammendrag lages lokalt (Ollama); e-post forlater aldri maskinen til en tredjepartsmodell.', 'Embeddings og sammendrag lages lokalt (Ollama) som standard; en valgfri sky-fallback finnes, men e-post forlater aldri maskinen med mindre det er eksplisitt skrudd på.',
'Polly retry/backoff mot Gmails rategrenser; strukturert Serilog-logging som aldri lagrer tokens.', 'Polly retry/backoff mot Gmails rategrenser; strukturert Serilog-logging som aldri lagrer tokens; OpenTelemetry-instrumentering klar for produksjonsobservabilitet.',
'Clean Architecture holder lagene testbare; integrasjonstester starter API-verten og sjekker autorisasjon.', 'Clean Architecture holder lagene testbare; 39+ enhets- og integrasjonstester, inkludert live-database-tester for fulltekst-/fuzzy-/pgvector-stiene.',
], ],
}, },
], ],
@@ -340,7 +468,31 @@ export const inboxintel: Project = {
blocks: [ blocks: [
{ {
type: 'p', type: 'p',
text: 'Under aktiv utvikling. Søk, sammendrag og analyse fungerer ende til ende; neste steg er å justere tersklene for semantisk gjenfinning, utvide integrasjonstestene, og jobbe mot koblinger utover Gmail slik at det blir et virkelig universelt innboks-verktøy.', text: 'Implementert i dag:',
},
{
type: 'ul',
items: [
'Gmail OAuth-synk inn i PostgreSQL, med gjenopptakbar, sjekkpunktet bakgrunnssynk',
'Hybrid semantisk + fulltekst + fuzzy søk med keyset-paginering',
'Flyttbart analysedashbord: innboks-helse, kategorier, volum, toppavsendere',
'Trygg massesletting og avmelding (forhåndsvis-så-bekreft, SSRF-sikret)',
'Valgfri lokal AI (Ollama) eller sky-fallback for klassifisering, sammendrag og naturlig-språk-søk',
'En egen-revidert, utbedret sikkerhetsstatus med sporede funn og fikser',
],
},
{
type: 'p',
text: 'Designet, men ikke bygget ennå — de neste store milepælene:',
},
{
type: 'ul',
items: [
'Støtte for flere leverandører (Outlook, generisk IMAP) — arkitektur og migreringsplan allerede skrevet',
'Regelmotor for automatisert opprydding, avsenderpolicy (blokker/hvitliste/screener) og en aktivitetslogg med angre',
'En personvernvakt (lekkasjesjekk mot kjente bruddatabaser)',
'Justerte AI-prompter og rikere HTML-body-/vedleggsparsing utover de vanlige tilfellene',
],
}, },
], ],
}, },
+207 -59
View File
@@ -1,6 +1,6 @@
import type { Project } from '@lib/schema'; import type { Project } from '@lib/schema';
/* JobTrack case study. Source: D:\Job tracker + current app mockups. */ /* JobTrack case study. Source: D:\Job tracker (full codebase + docs audit, 2026-07-12). */
export const jobtrack: Project = { export const jobtrack: Project = {
id: 'jobtrack', id: 'jobtrack',
name: 'JobTrack', name: 'JobTrack',
@@ -8,11 +8,14 @@ export const jobtrack: Project = {
status: 'in-development', status: 'in-development',
template: 'case-study', template: 'case-study',
stack: [ stack: [
{ name: 'ASP.NET Core', context: { en: '.NET API', no: '.NET-API' } }, { name: 'ASP.NET Core', context: { en: '.NET 9 API', no: '.NET 9-API' } },
{ name: 'React', context: { en: 'TypeScript SPA', no: 'TypeScript-SPA' } }, { name: 'Next.js', context: { en: 'React 19 · TypeScript', no: 'React 19 · TypeScript' } },
{ name: 'EF Core' }, { name: 'EF Core', context: { en: 'SQLite · MySQL/MariaDB', no: 'SQLite · MySQL/MariaDB' } },
{ name: 'FastAPI', context: { en: 'local AI service', no: 'lokal AI-tjeneste' } }, { name: 'FastAPI + Ollama', context: { en: 'local AI service', no: 'lokal AI-tjeneste' } },
{ name: 'Gmail OAuth' }, {
name: 'Multi-provider email',
context: { en: 'Gmail · Microsoft · IMAP', no: 'Gmail · Microsoft · IMAP' },
},
{ name: 'Docker' }, { name: 'Docker' },
], ],
links: [], links: [],
@@ -20,8 +23,8 @@ export const jobtrack: Project = {
viewBox: '0 0 1120 300', viewBox: '0 0 1120 300',
title: { en: 'JobTrack architecture', no: 'JobTrack-arkitektur' }, title: { en: 'JobTrack architecture', no: 'JobTrack-arkitektur' },
desc: { desc: {
en: 'A React single-page app talks to an ASP.NET Core API, which persists via EF Core with attachments on disk, calls a FastAPI/Ollama AI service, and imports from the external Gmail API over OAuth2.', en: 'A Next.js single-page app talks to an ASP.NET Core API, which persists via EF Core with attachments on disk, calls a FastAPI/Ollama AI service, and syncs correspondence from Gmail, Microsoft Graph and IMAP through one provider abstraction.',
no: 'En React-app snakker med et ASP.NET Core-API som lagrer via EF Core med vedlegg på disk, kaller en FastAPI/Ollama AI-tjeneste, og importerer fra det eksterne Gmail-API-et via OAuth2.', no: 'En Next.js-app snakker med et ASP.NET Core-API som lagrer via EF Core med vedlegg på disk, kaller en FastAPI/Ollama AI-tjeneste, og synkroniserer korrespondanse fra Gmail, Microsoft Graph og IMAP gjennom én felles abstraksjon.',
}, },
nodes: [ nodes: [
{ {
@@ -31,8 +34,8 @@ export const jobtrack: Project = {
w: 180, w: 180,
h: 72, h: 72,
kind: 'internal', kind: 'internal',
label: 'React SPA', label: 'Next.js SPA',
sub: { en: 'nginx · PWA', no: 'nginx · PWA' }, sub: { en: 'nginx · CSR', no: 'nginx · CSR' },
}, },
{ {
id: 'api', id: 'api',
@@ -71,15 +74,15 @@ export const jobtrack: Project = {
w: 180, w: 180,
h: 72, h: 72,
kind: 'external', kind: 'external',
label: 'Gmail API', label: 'Email providers',
sub: { en: 'external · OAuth2', no: 'eksternt · OAuth2' }, sub: { en: 'Gmail · MS Graph · IMAP', no: 'Gmail · MS Graph · IMAP' },
}, },
], ],
edges: [ edges: [
{ d: 'M220 148 H300', kind: 'flow', label: '/api', labelX: 248, labelY: 140 }, { d: 'M220 148 H300', kind: 'flow', label: '/api', labelX: 248, labelY: 140 },
{ d: 'M500 130 L600 88', kind: 'flow' }, { d: 'M500 130 L600 88', kind: 'flow' },
{ d: 'M500 166 L600 200', kind: 'flow' }, { d: 'M500 166 L600 200', kind: 'flow' },
{ d: 'M500 148 H900', kind: 'external', label: 'import', labelX: 690, labelY: 140 }, { d: 'M500 148 H900', kind: 'external', label: 'sync', labelX: 690, labelY: 140 },
], ],
}, },
media: [ media: [
@@ -130,9 +133,10 @@ export const jobtrack: Project = {
cardTeaser: cardTeaser:
'Import a role, tailor your CV, and track every application — recruiter threads and a Kanban pipeline in one workspace.', 'Import a role, tailor your CV, and track every application — recruiter threads and a Kanban pipeline in one workspace.',
tldr: { tldr: {
what: 'Full-stack job-search workspace: pipeline, CV match, Gmail threads, attachments, analytics.', what: 'Full-stack job-search workspace: pipeline, deterministic CV match, multi-provider recruiter threads, AI drafting.',
why: 'A real problem — my own job search needed production-grade tooling, not a spreadsheet.', why: 'A real problem — my own job search needed production-grade tooling, not a spreadsheet.',
stack: 'React + ASP.NET Core (EF Core) · FastAPI/Ollama local AI · Gmail OAuth · Docker.', stack:
'Next.js + ASP.NET Core (EF Core) · FastAPI/Ollama local AI · Gmail/Microsoft/IMAP · Docker.',
role: 'Everything: product, backend, frontend, ops and security.', role: 'Everything: product, backend, frontend, ops and security.',
}, },
sections: [ sections: [
@@ -143,55 +147,104 @@ export const jobtrack: Project = {
blocks: [ blocks: [
{ {
type: 'p', type: 'p',
text: 'A serious job search spreads across spreadsheets, email threads, notes apps and scattered documents. Nothing shows you, at a glance, which applications need attention or what was said last. I wanted one focused workspace — from importing a role to the final offer — built to the standard I would ship at work, not as a throwaway.', text: 'A serious job search spreads across spreadsheets, email threads, notes apps and scattered documents. Nothing shows you, at a glance, which applications need attention or what was said last, and every CV-tailoring tool either charges a subscription or hands back an opaque "match score" you can\'t interrogate. I wanted one focused workspace — from importing a role to the final offer — built to the standard I would ship at work, not as a throwaway.',
},
],
},
{
kind: 'solution',
heading: 'Solution',
anchorId: 'solution',
blocks: [
{
type: 'p',
text: 'JobTrack is a self-hosted workspace that carries one job application through its whole life: import the posting, see how your CV stacks up against it, track it through a pipeline, and keep every recruiter message attached to the right job — with an assistive AI layer that drafts but never sends anything on your behalf.',
},
{
type: 'ul',
items: [
'Pipeline: a Kanban board — Applied, Waiting, Interview, Offer, Rejected, Ghosted — drag to update, with rule-based auto-transition to Ghosted after inactivity.',
'CV match: deterministic keyword coverage (matched vs missing), not a black-box score.',
'Correspondence: one unified thread view across Gmail, Microsoft/Outlook and generic IMAP, auto-linked to the right job.',
'AI is assistive, never autonomous: it drafts CVs, cover letters and follow-ups — you always review and send.',
],
},
],
},
{
kind: 'features',
heading: 'Feature showcase',
anchorId: 'features',
features: [
{
title: 'Deterministic CV↔job match score',
value:
'See exactly which keywords from a posting your CV covers and which are missing — a stable, reproducible number instead of a $50/month "AI fit score" you can\'t argue with.',
technical:
'A from-scratch weighted-keyword algorithm (JobCvMatchService): curated skill tags at weight 3 plus a title bonus, frequency-ranked stop-word-filtered posting terms at weight 1, matched with a hand-rolled word-boundary check so "go" never matches "goal". No AI call — same inputs always produce the same score, with an honest "not enough signal" flag instead of false confidence.',
},
{
title: 'One thread, any provider',
value:
'Recruiter emails land in the right job automatically, whether they come through Gmail, Outlook or any other IMAP mailbox — no more digging through three inboxes to find what a recruiter said.',
technical:
'Correspondence import runs through a single IEmailProvider interface (SearchAsync / ListThreadMessagesAsync / GetMessageAsync) behind a provider registry, with a Provider discriminator column on each stored message. Gmail shipped first; Microsoft Graph and IMAP were added later as separate implementations of the same contract — no controller logic was duplicated per provider.',
},
{
title: 'AI-assisted drafting, human-gated sending',
value:
'Get a tailored CV, cover letter or follow-up draft in seconds — but nothing is ever sent or auto-applied without you reading and approving it first.',
technical:
'CVs are OCR/text-extracted (FastAPI + Tesseract/PyMuPDF), classified into structured blocks by a local Ollama model (qwen2.5:7b), then rendered to PDF via a templated Playwright export. Prompts wrap untrusted CV text in explicit delimiters with an instruction to treat it as inert data, not commands — closing off prompt injection from a CV someone else wrote.',
}, },
], ],
}, },
{ {
kind: 'architecture', kind: 'architecture',
heading: 'Architecture', heading: 'Engineering highlights',
anchorId: 'architecture', anchorId: 'architecture',
blocks: [ blocks: [
{ {
type: 'p', type: 'p',
text: 'A React + TypeScript SPA talks to an ASP.NET Core API that owns the domain logic — pipeline, follow-up rules, CV keyword matching — persisting via EF Core with attachments on disk. A small FastAPI service backed by a local Ollama model drafts CVs, cover letters and follow-ups, and the API imports correspondence from the Gmail API over OAuth2. The whole thing runs behind one Docker Compose file.', text: 'The backend is a modular monolith on ASP.NET Core (.NET 9) with six hosted background services — rules engine, follow-up reminders, scheduled exports, job enrichment, CV processing and an AI-readiness probe — running off the request path. EF Core targets SQLite by default and switches to MySQL/MariaDB via the Pomelo provider for larger deployments. Every tenant table is scoped by a global EF Core query filter (deny-on-null by design), so one database safely serves multiple users.',
}, },
{ {
type: 'ul', type: 'ul',
items: [ items: [
'Pipeline: a Kanban board — Applied, Waiting, Interview, Offer, Rejected, Ghosted — drag to update.', 'Auth: a custom policy scheme routes Google-issued, Microsoft-issued and local JWTs to the right handler; CSRF double-submit on cookie sessions; rate-limited login and email-sending endpoints.',
'CV match: deterministic keyword coverage (matched vs missing), not a black-box score.', 'CI/CD: Gitea Actions builds and tests the backend, builds the frontend, then SSHes into the production host and redeploys via Docker Compose on every push to main — no staging environment, by deliberate choice.',
'Gmail: import full threads over OAuth2; linked threads auto-refresh onto the right job.', 'i18n: full English + Norwegian Bokmål UI, a genuine differentiator against the mostly US-centric competitors (Teal, Huntr, Simplify).',
'AI is assistive, never autonomous: it drafts, you always review and send — no auto-apply.',
], ],
}, },
], ],
}, },
{ {
kind: 'decisions', kind: 'decisions',
heading: 'Key decisions & trade-offs', heading: 'Challenges',
anchorId: 'decisions', anchorId: 'decisions',
decisions: [ decisions: [
{ {
n: 1, n: 1,
choice: 'Run the AI locally with Ollama instead of a cloud API.', choice:
alternative: 'A hosted LLM API would have been faster to wire up.', 'Generalize Gmail-only import into a provider-neutral IEmailProvider abstraction, incrementally.',
alternative:
'Rewrite the correspondence layer once, up front, for every provider we might ever need.',
rationale: rationale:
'Job-search data is sensitive and I wanted zero per-call cost and no third party in the loop. The trade-off is more setup and heavier local resources — acceptable for a self-hosted tool.', 'Import started Gmail-only; when Microsoft and IMAP support were requested later, I extracted the interface across several small PRs instead of a big-bang rewrite. Outcome: three working providers behind one contract, and no controller ever had to be duplicated per provider.',
}, },
{ {
n: 2, n: 2,
choice: 'Make CV matching deterministic, not an AI score.', choice: 'SSRF-harden the job-import URL fetcher before shipping it.',
alternative: 'Let the model rate the fit.', alternative: 'Trust the pasted URL and fetch it directly — it is only a job posting.',
rationale: rationale:
'A number a candidate cant interrogate is useless. Deterministic keyword coverage shows exactly which terms matched and which are missing, so the advice is honest and actionable.', 'Letting users paste an arbitrary URL for server-side scraping is a classic path into internal infrastructure. The fetcher resolves the hostname via DNS rather than trusting the literal host, then rejects the resolved IP against the full private/reserved-range table (RFC 1918, CGNAT, link-local, IPv6 unique-local) for both IPv4 and IPv6. Outcome: independently re-verified as fixed in a follow-up security pass.',
}, },
{ {
n: 3, n: 3,
choice: 'Ship the PWA with no offline service-worker cache.', choice: 'Keep CV matching deterministic instead of an AI-generated score.',
alternative: 'A cache would enable full offline use.', alternative: 'Let the local model rate the fit directly.',
rationale: rationale:
'I deploy frequently, so an aggressive cache risks serving stale builds — a worse failure than a brief offline gap. The manifest still provides installability and share-to-capture. A deliberate anti-feature.', 'A number a candidate can\'t interrogate is useless, and it\'s the exact "AI slop" complaint competitor research turned up against existing tools. Deterministic keyword coverage shows precisely which terms matched and which are missing, so the advice is honest and actionable — and reproducible.',
}, },
], ],
}, },
@@ -203,10 +256,10 @@ export const jobtrack: Project = {
{ {
type: 'ul', type: 'ul',
items: [ items: [
'Optional Google sign-in (Google ID tokens) protects the API; every record is scoped to its owner.', 'Google and Microsoft sign-in (issuer-routed policy scheme) protect the API; every record is scoped to its owner via EF Core global query filters.',
'File uploads are validated and stored per-application with ownership checks on every access.', 'File uploads are validated and stored per-application with ownership checks on every access.',
'The AI layer is advisory only — it drafts, it never sends or auto-applies.', 'The AI layer is advisory only — it drafts, it never sends or auto-applies.',
'Runs as a reproducible Docker Compose stack with a documented .env; JSON/CSV exports for data portability.', 'Runs as a reproducible Docker Compose stack with a documented .env; JSON/CSV exports and automated database backups for data portability.',
], ],
}, },
], ],
@@ -218,12 +271,35 @@ export const jobtrack: Project = {
}, },
{ {
kind: 'next', kind: 'next',
heading: 'Status & whats next', heading: 'Status & roadmap',
anchorId: 'next', anchorId: 'next',
blocks: [ blocks: [
{ {
type: 'p', type: 'p',
text: 'In active development. The follow-up rules are simple date logic Id like to make configurable per stage; next up is a proper integration-test pass around the Gmail import edge cases and tighter grounding on the AI drafts now that I have real usage to learn from.', text: 'Implemented today:',
},
{
type: 'ul',
items: [
'Kanban pipeline with rule-based stage automation',
'Deterministic CV↔job match scoring',
'Multi-provider correspondence (Gmail, Microsoft Graph, IMAP)',
'AI-assisted CV, cover-letter and follow-up drafting (local Ollama, optional cloud fallback)',
'Attachments, reminders, JSON/CSV exports and automated backups',
],
},
{
type: 'p',
text: 'Planned next:',
},
{
type: 'ul',
items: [
'An interview hub and lightweight recruiter/contacts CRM',
'A second analytics pass and a proper PWA offline mode',
'Wider integration-test coverage around correspondence-import edge cases',
'Configurable per-stage follow-up rules (currently simple date logic)',
],
}, },
], ],
}, },
@@ -235,9 +311,9 @@ export const jobtrack: Project = {
cardTeaser: cardTeaser:
'Importer en stilling, tilpass CV-en, og følg hver søknad — rekruttør-tråder og en Kanban-pipeline i ett arbeidsrom.', 'Importer en stilling, tilpass CV-en, og følg hver søknad — rekruttør-tråder og en Kanban-pipeline i ett arbeidsrom.',
tldr: { tldr: {
what: 'Fullstack arbeidsrom for jobbsøking: pipeline, CV-match, Gmail-tråder, vedlegg og analyse.', what: 'Fullstack arbeidsrom for jobbsøking: pipeline, deterministisk CV-match, korrespondanse fra flere kanaler, AI-utkast.',
why: 'Et ekte problem — min egen jobbsøking trengte skikkelig verktøy, ikke et regneark.', why: 'Et ekte problem — min egen jobbsøking trengte skikkelig verktøy, ikke et regneark.',
stack: 'React + ASP.NET Core (EF Core) · FastAPI/Ollama lokal AI · Gmail OAuth · Docker.', stack: 'Next.js + ASP.NET Core (EF Core) · FastAPI/Ollama lokal AI · Gmail/Microsoft/IMAP · Docker.',
role: 'Alt: produkt, backend, frontend, drift og sikkerhet.', role: 'Alt: produkt, backend, frontend, drift og sikkerhet.',
}, },
sections: [ sections: [
@@ -248,55 +324,104 @@ export const jobtrack: Project = {
blocks: [ blocks: [
{ {
type: 'p', type: 'p',
text: 'En seriøs jobbsøking sprer seg over regneark, e-posttråder, notatapper og løse dokumenter. Ingenting viser deg med ett blikk hvilke søknader som trenger oppfølging, eller hva som sist ble sagt. Jeg ville ha ett samlet arbeidsrom — fra import av en stilling til endelig tilbud — bygget til samme standard som jeg leverer på jobb.', text: 'En seriøs jobbsøking sprer seg over regneark, e-posttråder, notatapper og løse dokumenter. Ingenting viser deg med ett blikk hvilke søknader som trenger oppfølging, eller hva som sist ble sagt — og alle CV-tilpasningsverktøy krever enten abonnement eller gir deg en uigjennomsiktig "match-score" du ikke kan etterprøve. Jeg ville ha ett samlet arbeidsrom — fra import av en stilling til endelig tilbud — bygget til samme standard som jeg leverer på jobb.',
},
],
},
{
kind: 'solution',
heading: 'Løsning',
anchorId: 'losning',
blocks: [
{
type: 'p',
text: 'JobTrack er et egendriftet arbeidsrom som følger én søknad gjennom hele livsløpet: importer stillingen, se hvordan CV-en din står seg mot den, følg den gjennom en pipeline, og hold hver rekruttør-melding koblet til riktig jobb — med et assisterende AI-lag som skriver utkast, men aldri sender noe på dine vegne.',
},
{
type: 'ul',
items: [
'Pipeline: et Kanban-brett — Søkt, Venter, Intervju, Tilbud, Avslått, Ghostet — dra for å oppdatere, med regelstyrt auto-overgang til Ghostet ved inaktivitet.',
'CV-match: deterministisk nøkkelorddekning (treff vs. mangler), ikke en svart boks.',
'Korrespondanse: én samlet tråd-visning på tvers av Gmail, Microsoft/Outlook og generisk IMAP, automatisk koblet til riktig jobb.',
'AI-en er assisterende, aldri autonom: den skriver utkast til CV, søknadsbrev og oppfølging — du gjennomgår og sender alltid selv.',
],
},
],
},
{
kind: 'features',
heading: 'Utvalgte funksjoner',
anchorId: 'funksjoner',
features: [
{
title: 'Deterministisk CV↔jobb-match',
value:
'Se nøyaktig hvilke nøkkelord fra stillingsannonsen CV-en din dekker, og hvilke som mangler — et stabilt, reproduserbart tall i stedet for en uigjennomsiktig "AI-score" til 50 dollar i måneden.',
technical:
'En egenutviklet, vektet nøkkelordalgoritme (JobCvMatchService): kuraterte kompetanse-tagger med vekt 3 pluss tittelbonus, frekvensrangerte, stoppord-filtrerte annonsetermer med vekt 1, matchet med en håndbygget ordgrense-sjekk så «go» aldri treffer «goal». Ingen AI-kall — samme input gir alltid samme score, med et ærlig «for lite signal»-flagg i stedet for falsk trygghet.',
},
{
title: 'Én tråd, uansett leverandør',
value:
'Rekruttør-eposter havner automatisk på riktig jobb, uansett om de kommer via Gmail, Outlook eller en annen IMAP-postkasse — ikke flere tre-innbokser å lete gjennom for å finne hva en rekrutterer sa.',
technical:
'Import av korrespondanse går gjennom ett felles IEmailProvider-grensesnitt (SearchAsync / ListThreadMessagesAsync / GetMessageAsync) bak et leverandørregister, med en Provider-kolonne på hver lagrede melding. Gmail kom først; Microsoft Graph og IMAP ble lagt til senere som separate implementasjoner av samme kontrakt — ingen kontrollerlogikk ble duplisert per leverandør.',
},
{
title: 'AI-assistert utkast, menneske-styrt sending',
value:
'Få et tilpasset CV-, søknadsbrev- eller oppfølgingsutkast på sekunder — men ingenting sendes eller søkes automatisk uten at du har lest og godkjent det først.',
technical:
'CV-er tekstutrekkes med OCR (FastAPI + Tesseract/PyMuPDF), klassifiseres i strukturerte blokker av en lokal Ollama-modell (qwen2.5:7b), og rendres til PDF via en malbasert Playwright-eksport. Prompter pakker inn utrygg CV-tekst i eksplisitte skilletegn med instruks om å behandle den som data, ikke kommandoer — som stenger av prompt-injeksjon fra en CV noen andre har skrevet.',
}, },
], ],
}, },
{ {
kind: 'architecture', kind: 'architecture',
heading: 'Arkitektur', heading: 'Tekniske høydepunkter',
anchorId: 'arkitektur', anchorId: 'arkitektur',
blocks: [ blocks: [
{ {
type: 'p', type: 'p',
text: 'En React + TypeScript-SPA snakker med et ASP.NET Core-API som eier domenelogikken — pipeline, oppfølgingsregler, CV-nøkkelordmatch — og lagrer via EF Core med vedlegg på disk. En liten FastAPI-tjeneste med en lokal Ollama-modell skriver utkast til CV, søknadsbrev og oppfølging, og API-et importerer korrespondanse fra Gmail-API-et via OAuth2. Alt kjører bak én Docker Compose-fil.', text: 'Backend er en modulær monolitt på ASP.NET Core (.NET 9) med seks bakgrunnstjenester — regelmotor, oppfølgingspåminnelser, planlagte eksporter, jobb-berikelse, CV-prosessering og en AI-klarhetssjekk — som kjører utenfor forespørselsløpet. EF Core bruker SQLite som standard og bytter til MySQL/MariaDB via Pomelo-driveren for større driftssettinger. Hver leietaker-tabell er avgrenset av et globalt EF Core-spørringsfilter (nekt-ved-null by design), så én database trygt kan betjene flere brukere.',
}, },
{ {
type: 'ul', type: 'ul',
items: [ items: [
'Pipeline: et Kanban-brett — Søkt, Venter, Intervju, Tilbud, Avslått, Ghostet — dra for å oppdatere.', 'Autentisering: et tilpasset policy-skjema ruter Google-utstedte, Microsoft-utstedte og lokale JWT-er til riktig håndterer; CSRF-dobbeltinnsending på cookie-økter; ratebegrenset innlogging og e-postutsending.',
'CV-match: deterministisk nøkkelorddekning (treff vs. mangler), ikke en svart boks.', 'CI/CD: Gitea Actions bygger og tester backend, bygger frontend, og SSH-er så inn på produksjonsserveren og ruller ut på nytt via Docker Compose ved hver push til main — ingen staging-miljø, et bevisst valg.',
'Gmail: importer hele tråder via OAuth2; koblede tråder oppdateres automatisk på riktig jobb.', 'i18n: fullt engelsk + norsk bokmål-grensesnitt, en reell differensiator mot de mest USA-sentrerte konkurrentene (Teal, Huntr, Simplify).',
'AI-en er assisterende, aldri autonom: den skriver utkast, du gjennomgår og sender — ingen auto-søking.',
], ],
}, },
], ],
}, },
{ {
kind: 'decisions', kind: 'decisions',
heading: 'Viktige valg og avveininger', heading: 'Utfordringer',
anchorId: 'beslutninger', anchorId: 'utfordringer',
decisions: [ decisions: [
{ {
n: 1, n: 1,
choice: 'Kjøre AI-en lokalt med Ollama i stedet for et sky-API.', choice:
alternative: 'Et hostet LLM-API hadde vært raskere å koble til.', 'Generalisere Gmail-bare import til en leverandørnøytral IEmailProvider-abstraksjon, trinnvis.',
alternative:
'Skrive om korrespondanselaget én gang, på forhånd, for hver leverandør vi kanskje trenger.',
rationale: rationale:
'Jobbsøkerdata er sensitivt, og jeg ville ha null kostnad per kall og ingen tredjepart i loopen. Avveiningen er mer oppsett og tyngre lokale ressurser — greit for et egendriftet verktøy.', 'Import startet Gmail-bare; da Microsoft- og IMAP-støtte ble etterspurt senere, trakk jeg ut grensesnittet over flere små PR-er i stedet for en stor omskriving. Resultat: tre fungerende leverandører bak én kontrakt, og ingen kontroller måtte dupliseres per leverandør.',
}, },
{ {
n: 2, n: 2,
choice: 'Gjøre CV-matchen deterministisk, ikke en AI-score.', choice: 'SSRF-sikre jobb-import-URL-henteren før den ble sluppet.',
alternative: 'La modellen vurdere treffet.', alternative: 'Stole på den limte inn URL-en og hente den direkte — det er jo bare en stillingsannonse.',
rationale: rationale:
'Et tall du ikke kan etterprøve er ubrukelig. Deterministisk nøkkelorddekning viser nøyaktig hvilke ord som traff og hvilke som mangler, så rådet er ærlig og handlingsrettet.', 'Å la brukere lime inn en vilkårlig URL for server-side skraping er en klassisk vei inn i intern infrastruktur. Henteren slår opp vertsnavnet via DNS i stedet for å stole på den bokstavelige verten, og avviser så den oppløste IP-en mot hele det private/reserverte adresseområdet (RFC 1918, CGNAT, link-lokal, IPv6 unique-local) for både IPv4 og IPv6. Resultat: uavhengig verifisert som fikset i en påfølgende sikkerhetsgjennomgang.',
}, },
{ {
n: 3, n: 3,
choice: 'Levere PWA-en uten offline-cache i service-workeren.', choice: 'Holde CV-matchen deterministisk i stedet for en AI-generert score.',
alternative: 'En cache ville gitt full offline-bruk.', alternative: 'La den lokale modellen vurdere treffet direkte.',
rationale: rationale:
'Jeg ruller ut ofte, så en aggressiv cache risikerer å servere utdaterte bygg — en verre feil enn et kort offline-hull. Manifestet gir fortsatt installerbarhet og share-to-capture. En bevisst anti-funksjon.', 'Et tall en kandidat ikke kan etterprøve er ubrukelig, og det er nøyaktig «AI-slop»-klagen konkurrentanalysen fant mot eksisterende verktøy. Deterministisk nøkkelorddekning viser nøyaktig hvilke ord som traff og hvilke som mangler, så rådet er ærlig, handlingsrettet — og reproduserbart.',
}, },
], ],
}, },
@@ -308,10 +433,10 @@ export const jobtrack: Project = {
{ {
type: 'ul', type: 'ul',
items: [ items: [
'Valgfri Google-innlogging (Google ID-tokens) beskytter API-et; hver post er knyttet til sin eier.', 'Google- og Microsoft-innlogging (utsteder-rutet policy-skjema) beskytter API-et; hver post er knyttet til sin eier via globale EF Core-spørringsfilter.',
'Filopplastinger valideres og lagres per søknad, med eierskapssjekk ved hvert tilgangspunkt.', 'Filopplastinger valideres og lagres per søknad, med eierskapssjekk ved hvert tilgangspunkt.',
'AI-laget er kun rådgivende — det skriver utkast, det sender aldri og søker aldri automatisk.', 'AI-laget er kun rådgivende — det skriver utkast, det sender aldri og søker aldri automatisk.',
'Kjører som en reproduserbar Docker Compose-stack med dokumentert .env; JSON/CSV-eksport for dataportabilitet.', 'Kjører som en reproduserbar Docker Compose-stack med dokumentert .env; JSON/CSV-eksport og automatiske databasebackupper for dataportabilitet.',
], ],
}, },
], ],
@@ -328,7 +453,30 @@ export const jobtrack: Project = {
blocks: [ blocks: [
{ {
type: 'p', type: 'p',
text: 'Under aktiv utvikling. Oppfølgingsreglene er enkel datologikk jeg vil gjøre konfigurerbar per stadium; neste steg er skikkelige integrasjonstester rundt kanttilfellene i Gmail-importen og strammere forankring av AI-utkastene nå som jeg har ekte bruk å lære av.', text: 'Implementert i dag:',
},
{
type: 'ul',
items: [
'Kanban-pipeline med regelstyrt stadie-automasjon',
'Deterministisk CV↔jobb-match',
'Korrespondanse fra flere kanaler (Gmail, Microsoft Graph, IMAP)',
'AI-assistert utkast til CV, søknadsbrev og oppfølging (lokal Ollama, valgfri sky-fallback)',
'Vedlegg, påminnelser, JSON/CSV-eksport og automatiske backupper',
],
},
{
type: 'p',
text: 'Planlagt videre:',
},
{
type: 'ul',
items: [
'Et intervjuknutepunkt og en enkel rekrutterer/kontakt-CRM',
'En ny analyserunde og et skikkelig offline-modus for PWA-en',
'Bredere integrasjonstestdekning rundt kanttilfeller i korrespondanse-import',
'Konfigurerbare oppfølgingsregler per stadium (i dag enkel datologikk)',
],
}, },
], ],
}, },
+9
View File
@@ -72,8 +72,16 @@ export const decision = z.object({
rationale: z.string(), rationale: z.string(),
}); });
export const feature = z.object({
title: z.string(),
value: z.string(),
technical: z.string(),
});
export const SECTION_KINDS = [ export const SECTION_KINDS = [
'problem', 'problem',
'solution',
'features',
'architecture', 'architecture',
'decisions', 'decisions',
'security', 'security',
@@ -87,6 +95,7 @@ export const section = z.object({
anchorId: z.string(), anchorId: z.string(),
blocks: z.array(block).optional(), blocks: z.array(block).optional(),
decisions: z.array(decision).optional(), decisions: z.array(decision).optional(),
features: z.array(feature).optional(),
}); });
export const projectLocaleContent = z.object({ export const projectLocaleContent = z.object({