474654b1c10d1fd1532753aa894a8aeee0fd47af
162 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
474654b1c1 |
fix(cv): add LongTailJson to reconciler CareerProfiles schema
Production GET /api/cv/outline returned 500 "Unknown column 'c.LongTailJson'". CareerProfiles is reconciler-owned, but the reconciler's CREATE TABLE (both the SQLite and MySQL branches) only listed Id, OwnerUserId, ProfileJson, Version, CreatedAtUtc, UpdatedAtUtc. LongTailJson was added to the CareerProfile model in Phase 3 but neither CREATE was updated and no column-repair existed, so: - existing databases (prod): the MySQL CREATE is guarded on !HasMySqlTable, so it never runs once the table exists, and nothing adds the column -> LoadStructuredAsync selects a column that isn't there. - fresh databases: the CREATE itself omitted the column, so even a brand new MariaDB/SQLite was missing it. The 420 tests never caught this because they build tables from the EF model, not the reconciler DDL. The release audit missed it because it never exercised /api/cv/outline. Add LongTailJson to both CREATE statements and add an additive repair (EnsureColumn / EnsureMySqlColumn) for existing tables. DEFAULT '' backfills existing rows and matches the non-nullable model property. This is the sanctioned reconciler repair path, not a manual ALTER, and preserves existing data (ADD COLUMN is non-destructive). Verified on a real MariaDB 11 container: an existing 6-column CareerProfiles gains LongTailJson on startup (repair path), a fresh DB gets it from the CREATE (longtext), and GET /api/cv/outline returns 200. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
95646e1d53 |
fix(db): create follow-up reminder index on MariaDB
The reconciler's IX_JobApplications_OwnerUserId_FollowUpAt was declared as
(OwnerUserId(191), FollowUpAt) with no prefix length on FollowUpAt. But
FollowUpAt is `text` on MariaDB -- JobApplications is migration-owned and
the migration was scaffolded against SQLite, which stores DateTimeOffset
as TEXT. A text column cannot be indexed without a prefix length, so this
index failed the 3072-byte key check on EVERY MariaDB boot, was caught by
TryCreateIndex, and was silently skipped -- leaving the follow-up reminder
query (OwnerUserId + FollowUpAt) unindexed.
Two consequences, both real:
- the index the code intends to create never existed on MariaDB
- every healthy boot logged "Specified key was too long", which
deploy/first-production-deployment.md lists as a STOP-AND-ROLL-BACK
signal -- so an operator following the runbook could abort a good deploy
The author already handled the identical problem for the longtext Status
column one line below with Status(50). Apply the same fix: FollowUpAt(20).
ISO-8601 date strings sort lexicographically, so a 20-char prefix
("YYYY-MM-DD HH:MM:SS") keeps the index useful for the reminder scan.
Verified on a fresh empty MariaDB 11 container: the index is now created
(both key parts present), zero "Specified key was too long" lines, zero
skipped indexes, zero unhandled exceptions, 42 tables, app healthy. This
was the only unprefixed text column in any reconciler composite index --
the datetime columns on reconciler-owned tables are datetime(6). SQLite is
unaffected (its CREATE INDEX has no key-length limit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
8f6f2ba8d6 |
fix(health): report configured application version
/health read the APP_VERSION environment variable directly, but docker-compose passes App__Version, which binds to the App:Version configuration key. The variable under that name never existed in the container, so the endpoint always reported "unknown". Read App:Version through IConfiguration, the approach AdminSystemController already used for the same value. The resolution rule (configured version, else assembly version) moves to a shared BuildMetadata helper rather than being written twice; AdminSystemController now calls it, so the admin page and /health cannot drift apart. Local development is unaffected: nothing sets App:Version there, and the assembly-version fallback still applies. Tests pin the configuration KEY, not just the behaviour, including that an App__Version environment variable binds to App:Version. The original bug failed silently, so a behavioural test alone would not have caught it. Verified against a running backend: App__Version=9.9.9-test reports 9.9.9-test; unset reports the assembly version rather than "unknown". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
93462b799c |
chore(ops): add deployment backups restore docs and health checks
Closes the three operational blockers from the production readiness review. deploy.sh now takes a database backup before it builds, stops or replaces anything, and aborts the deploy if the backup fails — so no deploy proceeds without a restore point. Dumps are gzipped and timestamped into /opt/job-tracker/backups (override with BACKUP_DIR), so one deploy never overwrites an earlier backup. Credentials come from the existing connection string and travel via MYSQL_PWD, never on the command line, so they cannot reach the process list or the deploy log. A dump that is empty or missing CREATE TABLE is rejected, because a truncated file that looks like a restore point is worse than none. SQLite deployments get their data volume tarred instead. Nothing is ever deleted automatically; retention is documented as manual. deploy/README.md documents backup creation, location, retention, database restore, application rollback, and when to use which — restore and rollback kept distinct, because a bad deploy usually needs only the rollback and restoring would discard everything written since the dump. Health checks now cover backend and frontend, which previously had none. GET /health is anonymous, cheap, and deliberately does not touch the database: a health check that queried MariaDB would restart a healthy backend whenever the database blipped, and would hand out an unauthenticated way to probe database availability. The backend image gains curl on the existing chromium apt layer, since the aspnet runtime ships neither curl nor wget. frontend now waits for backend to be healthy rather than merely started, because nginx proxies /api and refuses to start when the upstream cannot be resolved. Verified against real containers, no production data: backup from a seeded MariaDB 11; restore into a clean MariaDB 11 with rows identical; bad credentials and a missing connection string both abort non-zero and leave no partial file; SQLite volume backup produces a readable archive; backend and frontend both reach healthy; and a backend pointed at an unreachable database exits and is reported unhealthy, so a broken deploy cannot present as a running stack. Incidentally confirmed the earlier authorization work: with Auth:Require unset, /health returns 200 while /api/jobapplications returns 401. 393 backend tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
432e1fd667 |
feat(timeline): emit application lifecycle events
The timeline could interpret InterviewScheduled, InterviewCompleted, OfferReceived and FollowUpCompleted, but only StatusChanged and FollowUpSet were ever written, so those branches never rendered. Events are now derived from the status TRANSITION in one shared emitter rather than at each call site, so the two status-change boundaries in JobApplicationsController cannot drift apart and a third would get the behaviour for free. Both boundaries now call it instead of hand-writing the StatusChanged block. Deriving from the transition rather than the resulting state is what prevents duplicates: one user action produces at most one lifecycle event, re-saving an unchanged status produces none, and reaching an offer twice records it once. Moving an application backwards is treated as a correction, not a completed interview, so only a forward move out of an interview stage counts. An Interview to Offer move reports the offer, which is the thing the user cares about. Completing a follow-up checklist item emits FollowUpCompleted, guarded on the same transition rule so re-saving a done item stays silent. The task itself remains a checklist item — this only records that it happened. No new history store: every event is a JobEvent row, which stays the single source of application history. 393 backend tests pass, including timeline rendering of the emitted events. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
c0bf69ad56 |
fix(security): enforce explicit api authorization
Authentication relied on a fallback policy gated on Auth:Require, which defaults to false. Five user-owned controllers carried no [Authorize] of their own, so a deployment that lost that flag would have served tenant data anonymously: JobApplications, Companies, Correspondence, Rules and JobImport. All five now declare [Authorize(AuthenticationSchemes = "local")] explicitly. This does not affect local development, which already sets Auth:Require=true in appsettings.Development.json — the gap was only ever in a production configuration that omitted the flag. Added a reflection test over every controller in the assembly so a new one cannot ship unprotected by accident. A controller passes if the class requires authorization, or if every action declares its own [Authorize] or [AllowAnonymous] — the shape AuthController and TwoFactorController need, since login and register must stay anonymous while the rest must not. Public endpoints are an explicit allow-list, so making something anonymous is now a deliberate edit rather than an omission. That test found one real gap: AuthController.Logout declared neither attribute. It is now explicitly [AllowAnonymous] — it only clears the caller's own session cookies and leaks nothing, and requiring authentication would leave a user whose token had already expired unable to sign out. Also pinned: admin controllers require the Admin role rather than merely a signed-in user, and PublicCvController stays anonymous so shared CV links keep working. 384 backend tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
fc132273f7 |
feat(ai): include application intelligence in interview preparation
Interview generation saw only the profile and the advert, so it produced generic questions. It now also receives what the workspace already computed: seniority, employment type, key requirements and advert technologies from the job analysis, plus the match score, the skills the candidate demonstrably has, the most relevant experience and projects — and above all the gaps, which is exactly what an interviewer probes. No second pipeline. The context comes from ApplicationIntelligenceService, which is deterministic and read-only, so this adds no AI call and cannot alter user data. Generation still runs through AiWorkspaceService and is still appended to AiInteraction. The dependency is optional, so existing constructions keep working and a missing intelligence service degrades to the previous prompt instead of failing. Only the interview module is affected; job-analysis, career-match, cover-letter and application-review assemble exactly as before. Suggestion-only is unchanged and now pinned by tests: generation adds an AiInteraction and nothing else, creates no InterviewPrepItem, leaves existing prep items and the CareerProfile untouched, and refuses another user's application. Context is scoped to the requesting user, so another user's profile is never scored in. 379 backend tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
3d74baef78 |
feat(workspace): interview and follow-up workflow
Phase 5.5. Completes the lifecycle after submission: prepare, communicate, chase. Interview preparation gets a durable, user-owned store. There were already two per-application AI stores, InterviewPrepNote and AiWorkspaceNote, but both are caches that regenerate when their context signature changes — anything a user typed into them would eventually be overwritten. InterviewPrepItem is the side nothing regenerates, covering company research, technical notes, behavioural answers, STAR examples and the user's own questions in one table, because those categories differ only by label and adding one must not need a migration. Each item records whether the user wrote it or accepted a suggestion, and an IsPrepared flag makes the section double as the preparation checklist. Generation stays in the existing AiWorkspaceService "interview" module, appended to AiInteraction as before. A suggestion is history until the user adds it as a prep item; opening the section generates nothing. Follow-up reuses what exists rather than adding a tracker. The date is JobApplication.FollowUpAt, the same field RulesEngine and the reminder hosted service already act on, so reminders keep working with no new wiring. The task stays an ApplicationChecklistItem in the follow-up category — the section counts open tasks without owning them. The record is a FollowUpSet JobEvent, the same type the rest of the app emits. Communication is untouched: Correspondence already owns recruiter contacts, history and notes, and the workspace already mounted it. The timeline interpreter learned five more types — InterviewScheduled, InterviewCompleted and OfferReceived as milestones, FollowUpCreated and FollowUpCompleted as routine, deliberately outside the milestone spine so it stays a summary of what actually happened. JobEvent remains the history source. InterviewPrepItems is reconciler-owned with a no-op migration, guarded on JobApplications, and verified on a fresh MariaDB 11: int AUTO_INCREMENT primary key, varchar owner and title, tinyint flag, datetime(6), composite index inside the key limit. 371 backend tests, 128 frontend tests, Release build and the production build all pass locally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
02b38f7acb |
feat(workspace): application assets workflow
Phase 5.4. Connects the career outputs a user already has to one job
application, without building a second copy of any of them.
The flow is strictly one-directional — CareerProfile -> CvVariant ->
application output — and nothing writes back up. No code path in this phase
touches CareerProfile or its children.
CV integration re-points rather than duplicates. GET/PUT /{id}/cv attaches one
variant to an application via CvVariant.JobApplicationId; replacing detaches the
previous variant instead of deleting it. Creating, duplicating, editing, theming,
previewing, exporting PDF and version history all stay in the existing CV
builder, which the section links into. There is no second CV system.
Tailoring composes the Phase 5.3 analysis and match into skills to highlight,
experience to prioritise, projects to emphasise, keywords to include and gaps to
address. Deterministic and advisory: it says what the user could emphasise and
the user edits the variant themselves. Nothing auto-applies.
Cover letters gain the history they were missing. JobApplication.CoverLetterText
stays the current text with its API contract unchanged; CoverLetterVersions
records what it used to be, so an AI rewrite is never destructive. Restore is
additive — the old text comes back as a new version, so what you restored from
still exists. Source and AiAction record whether the user wrote a version or
approved it from a suggestion, and an AI generation only becomes a version once
the user saves it.
Documents are untouched: the existing Attachment system already covers CV, cover
letter, certificates and portfolio files with a Purpose field, so the workspace
mounts that component rather than adding a second upload path.
CoverLetterVersions is the only new table — reconciler-owned, no-op migration,
guarded on JobApplications, and verified on a fresh MariaDB 11: int
AUTO_INCREMENT primary key, varchar(255) owner, datetime(6), composite index
inside the key limit.
360 backend tests, 115 frontend tests, type check, Release build and the
production build all pass locally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
a7cecce13d |
feat(workspace): add application intelligence
Phase 5.3. Three read-only reads that answer "how suitable is this job", "how
does my experience match", "what am I missing", "what happened previously".
Timeline (GET /{id}/timeline) is an interpretation layer over JobEvent, which
stays the source of historical truth. Each row gains a readable summary, a
category and a milestone flag; events group by day. Milestones are returned
unfiltered, because narrowing the detail must not hide what actually happened.
Job analysis (GET /{id}/analysis) extracts role, company, location, employment
type, seniority, salary, technologies, skills, responsibilities and keywords
from the advert, reusing the existing SkillTagger so the vocabulary matches the
job importer. It also reports what the advert does NOT say, which is usually the
more useful half.
Career matching (GET /{id}/match) feeds the master CareerProfile into the same
JobCvMatchService the CV builder uses, so one application scores identically
whichever surface asks. It returns the score, matched and missing skills, and
which experience and project entries are the evidence for each match.
All three are deterministic and own no data — no new table, no new column, and
nothing writes to the CareerProfile, a CvVariant, or the JobApplication. The AI
narrative stays where it already was, in AiWorkspaceService's job-analysis and
career-match modules, generated only when the user asks and versioned by the
append-only AiInteraction history. Opening a section costs nothing and changes
nothing.
Frontend adds Timeline, Analysis and Match sections to the workspace, sharing
one loader so loading, empty and error states are consistent. The deterministic
answer renders first, with the AI panel below it.
345 backend tests, 104 frontend tests, type check, production build all pass
locally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
7f426e255c |
fix(infrastructure): support clean MariaDB initialization
A completely empty MariaDB database could not start: the reconciler assumed migration-owned tables already existed, and migrations assumed reconciler-owned tables already existed. Neither could go first. Existing databases worked, so only fresh installs were affected. Startup is now an explicit sequence: connect, reconcile, migrate, reconcile, start. The reconciler runs twice because neither position alone works — pass 1 repairs legacy schemas and creates the reconciler-owned tables that migrations reference, pass 2 picks up everything that could not exist yet on a fresh database. Every statement is existence-guarded, so the second pass is a no-op scan on a correct database. Untangled the overlapping ownership: - RuleSettings is migration-owned. The reconciler also created it, which made a clean install fail with "Table 'RuleSettings' already exists". It now only seeds the default row, and only once the table exists. - The six CareerProfile child tables are reconciler-owned. Their migration was scaffolded against SQLite and indexed an unbounded longtext OwnerUserId, which exceeds MariaDB's 3072-byte key limit; it is now a no-op and the reconciler carries correct per-provider DDL. OwnerUserId and ItemKey are bounded to varchar(255) in the model so the index fits. - Reconciler tables that reference another table are guarded on their parent, so pass 1 skips them on an empty database instead of failing on the foreign key. - All index creation goes through one EnsureMySqlIndex helper, guarded on table existence as well as index existence. This removes ten copies of the raw block that crashed on a missing table. - The DbContext-owned connection is no longer disposed by the reconciler, and Open() is guarded on connection state, so the second pass can reuse it. Verified against MariaDB 11 and SQLite: empty MariaDB (40 tables, starts), restart on the populated database (idempotent, rows preserved), empty MariaDB via the Docker image, fresh SQLite (42 tables), and an existing partially migrated SQLite dev database (34 tables upgraded to 44 with all 13 applications and 8 companies intact). 329 backend tests pass in Release. Ownership rules, startup order, fresh install and production upgrade are documented in docs/infrastructure/database-ownership.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
3a906b881e |
feat(workspace): unified application checklist (Phase 5 milestone 2)
Evolve the existing readiness workflow into one persisted, user-controlled checklist rather than adding a second tracker. ApplicationChecklistItem records only completion state and user intent. Each default system item carries a stable SystemKey and an AutoSignal — the same signal /readiness already computed — and re-syncs on every read: a satisfied signal auto-completes the item, a reverted signal reopens it, and a manual tick always wins. Users can add, reorder, dismiss and delete. Readiness is refactored into a projection of the checklist (score = completion percentage, completed/missing = live items by status). Its DTO shape and the workflowSignal/reminders health view are unchanged, so no API contract breaks. The workspace's next recommended action now comes from the first pending checklist item in category priority order (preparation, submission, follow-up, interview, custom), replacing the parallel ruleset — so the overview can never recommend something already ticked off, and a user's own task can be next. The table follows the established MariaDB-safe path: the scaffolded migration is a no-op and the idempotent reconciler owns the DDL for both providers. Verified on MariaDB 11 — auto_increment PK, varchar/datetime(6)/tinyint(1) columns, both indexes inside the key limit, cascade delete, unique system key per application, and NULL system keys not colliding for custom items. 329 backend tests, 94 frontend tests, type check, production build and both Docker builds pass locally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
e55a6e86b7 |
feat(workspace): Application Workspace foundation (Phase 5 milestone 1)
Every JobApplication gets a dedicated workspace at /applications/{id} — a
surface, not a new data store. It owns no data and duplicates none: CV comes
from the Phase 4 CvVariant lens, analysis/match/interview from the existing
AiWorkspacePanel, documents from Attachments, communication from
Correspondence, activity from JobEvent, stage semantics from JobPipeline. No
career data is copied and nothing here writes.
- GET /api/jobapplications/{id}/workspace: one aggregate read (role, company,
stage, dates, attached CV variant, cover letter, documents, AI history,
recent activity) replacing the page fanning out across endpoints
- Next recommended action: ordered rules answering "what do I do next?", the
core product principle for this phase
- ApplicationWorkspacePage: left nav + linkable ?section=, reusing the existing
component for each domain; later-milestone sections say so rather than faking
- Entry point from the job dialog via an optional onOpenWorkspace callback —
the dialog must not depend on router context (it is mounted without a
<Router> in several suites), so the caller owns navigation
- 8 backend tests (aggregate, CV variant surfacing, counts, activity ordering,
next-step rules, tenant scoping)
Local: 314 backend, 88/88 frontend (31 suites), tsc clean, production build ok.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
1430313a20 |
fix(db): provision CV builder + AI workspace tables via the MySQL-safe reconciler
Deploy failed on prod (MariaDB) while the test job was green: backend startup threw during Database.Migrate(), so deploy.sh's post-deploy health check exited. Root cause: AddCvVariants/AddAiInteractions were scaffolded against SQLite, so they bake SQLite type names into their DDL — DateTimeOffset emits `TEXT`, bool/int emit `INTEGER`, and the PK gets no AUTO_INCREMENT. Run against MariaDB that yields a structurally wrong table, and the composite index over a TEXT column then trips "ERROR 1071: Specified key was too long; max key length is 3072 bytes". SQLite accepts all of it, which is why local/container verification passed. Fix, following the pattern already used for CareerProfiles/AiWorkspaceNotes: - both migrations become no-ops; the three tables are reconciler-owned - reconciler provisions them idempotently per dialect (MySQL: varchar/int AUTO_INCREMENT/datetime(6); SQLite: CREATE TABLE IF NOT EXISTS) - DropMalformedMySqlTable rebuilds a half-built table left by the failed migration, guarded on row count so a table with ANY rows is never dropped - bound the indexed string columns with HasMaxLength so the model matches Verified against a real MariaDB 11 container: reproduced error 1071, then confirmed the corrected DDL yields auto_increment PKs, varchar/datetime columns and all previously-failing indexes. 306 backend tests green; SQLite container starts clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
f299d7be7c |
feat(ai): AI Workspace per job application — modules + append-only history
Phase 5 backend. A unified AI Workspace for each application, orchestrating the
five suggestion modules through the existing ISummarizerService provider
abstraction and storing every generation as append-only history (AiInteraction)
so outputs can be reused, compared, and deleted — distinct from the existing
AiWorkspaceNote cache (one row, overwritten).
Modules (all suggestion-only, "never invent facts" guardrail, never mutate the
profile/variant/application): job-analysis, career-match, cover-letter (6 modes),
interview, application-review. Each builds a prompt from the job + master profile
text and returns markdown.
- Models/AiInteraction.cs + migration AddAiInteractions (verified on container)
- Services/AiWorkspaceService.cs (prompts, history, delete)
- Controllers/AiWorkspaceController.cs (/api/jobapplications/{id}/ai:
generate, history, delete, modules+provider)
- 7 tests (store, history filter/order, delete, mode normalization, unknown
module, empty output, tenant scoping); 306 backend green
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
e3b255f226 |
feat(career): theme polish, rich-text bullets, entry ordering, outline API
Phase 4.5 backend enablers. - Themes (priority 3): AtsFriendly flag on single-column themes (surfaced in GET /api/cv/themes), print-quality page-break rules (entries never split across a page; headings stay with content; widow/orphan control), darkened the creative sidebar for AA contrast. - Rich text (priority 1): bullets/summary support **bold**, *italic*, __underline__, [text](url) via a safe inline pass — everything is HTML-escaped first, so no user tag can survive; only the whitelist emits markup. - Entry ordering (priority 1): CvSectionSetting.ItemOrder reorders entries within a section by ItemKey, never touching the master profile. - Outline API: GET /api/cv/outline returns the master profile as sections+entries with ItemKeys, so the Content tab can render editable per-item rows. - 3 new tests (22 total in the builder suite). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
a3e18e4b44 |
feat(career): CV builder backend — data-driven theme engine + variant model
Phase 4 foundation. A CvVariant is a lens over the master CareerProfile
(section order/visibility, per-item overrides keyed by ItemKey, theme +
builder settings) — it references career data, never duplicates it. One
renderer (ThemedCvRenderer) draws every theme; a theme is pure data
(CvThemeCatalog, 8 professional themes), so adding a theme needs no renderer
change. Autosave version history + non-destructive restore, public CV via
/api/public-cv/{slug} (anonymous, noindex, filter-bypassing owner load), and
an AI-assist endpoint reusing the existing provider abstraction (suggestions
only, never auto-applied).
- Models: CvVariant/CvVariantVersion, CvVariantSettings, CvTheme + catalog
- Services: CvVariantResolver (profile+lens -> render model), ThemedCvRenderer,
CvVariantService, CareerProfileService.LoadStructuredForOwnerAsync (public)
- API: CvVariantController (/api/cv), PublicCvController (/api/public-cv)
- Migration AddCvVariants (2 self-contained tables; verified applied on the
running container), 16 tests (resolver/renderer/service), 296 backend green
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
f1bf92a4e0 |
feat(career): add career profile versioning
Phase 3, version history (list + restore). CareerProfileVersions was already
populated on every save; this makes it usable.
- ICareerProfileService.ListVersionsAsync — the append-only history, newest first,
with the current version flagged.
- RestoreVersionAsync — reapplies a past snapshot NON-DESTRUCTIVELY: it is re-saved
as a new version, so the current state stays in history and the restore is itself
reversible. Syncs the relational children + blob projection like any save.
- Endpoints: GET /career/profile/versions, POST /career/profile/versions/{v}/restore.
Tests (+4): versions listed newest-first with current flagged; restore reapplies
an old snapshot as a new version (history preserved, reversible); restore of a
missing version returns null.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
2b57d65715 |
feat(career): wire /career to the relational profile API + completeness overview
Phase 3, frontend. /career now reads and writes the master profile through the
relational source of truth instead of the legacy blob path.
- CareerProfilePage loads GET /career/profile (structured profile from the
relational children + cvText + completeness) and saves PUT /career/profile
({ profile, cvText }). This keeps the relational store authoritative — the
previous PUT /auth/profile blob write left it stale after first load.
- Added a "Profile completeness" overview (percent bar + missing sections) at the
top of /career, from the server scorecard.
- PUT /career/profile now accepts { profile, cvText } so the single /career save
covers both the structured profile and the raw imported text; GET returns cvText.
Tests: career-save asserts the /career/profile payload; new completeness-overview
test; controller tests updated for the request wrapper. 75/76 frontend pass (the
1 failure is the unrelated pre-existing settings-view suite); prod build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
b203120ab4 |
feat(career): master career profile API
Phase 3, API layer. GET/PUT /api/career/profile — the endpoint the /career editor uses to read and write the master profile. - GET: returns the structured profile (assembled from the relational children, backfilled from the blob if needed) plus a completeness scorecard. - PUT: validates limits, persists via CareerProfileService (relational children + append-only version), then serializes the result into ApplicationUser.ProfileCvStructureJson so the legacy read paths stay in sync. Identity fields are untouched (they belong to /profile). - GET /completeness: just the scorecard, for the overview. - CareerCompleteness: weighted percent + missing sections. - CareerProfileValidator: item-count/length limits (abuse guard, NOT completeness — a work-in-progress profile always saves). Tests (+4): put/get round-trip + projection sync, completeness, over-limit rejection, empty WIP profile accepted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
46ff9454a8 |
feat(career): relational projection and backfill for the master profile
Phase 3, service layer. CareerProfileService now maintains the relational children as the source of truth for structured career data, with the StructuredCvProfile blob kept as a derived projection. - SaveVersionAsync additionally syncs the relational children (replace-all, preserving ItemKeys from the blob item ids; SortOrder = array position) and the LongTailJson (contact, summary, interests, other sections, metadata). - New LoadStructuredAsync reads the master profile from the relational children, lazily backfilling from the ProfileJson blob for profiles that predate Phase 3. - CareerProfileMapper: the two-way projection between relational rows and StructuredCvProfile. Tests (+5): round-trip through relational, item-key preservation, wholesale child replacement (no orphans), backfill from a pre-Phase-3 blob, empty profile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
3a4c8fbc10 |
feat(career): structured career profile foundation
Phase 3, schema layer. Relational children of CareerProfile — the editable master career profile. See docs/architecture/career-profile-model.md. - New entities (Models/CareerEntities.cs): CareerExperience, CareerEducation, CareerSkill, CareerProject, CareerCertification, CareerLanguage. Each carries OwnerUserId (tenant filter), a stable ItemKey (carried from the blob so future CV variants can reference items), and SortOrder. List fields persist as JSON string columns via [NotMapped] accessors — plain TEXT, reconciler-friendly. - CareerProfile gains typed child collections + a LongTailJson column (contact, summary, interests, achievements, orgs, pubs, courses, custom sections, metadata). ProfileJson becomes a derived projection for legacy read paths. - DbContext: DbSets + tenant query filters + ordered indexes; FK/cascade by convention via the typed collections. - Migration hand-edited to add only the 6 new tables + LongTailJson; the scaffolder re-emitted four reconciler-owned tables (AiWorkspaceNotes, CareerProfiles, InterviewPrepNotes, CareerProfileVersions) which were stripped. The regenerated snapshot now includes them, closing the drift. Verified against a copy of the real dev DB: applies cleanly, no data loss. Long tail (achievements/orgs/pubs/courses) starts as JSON; promotable to relational later without a source-of-truth change. Source-of-truth flip stays deferred; the blob is kept as a derived projection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
66cc6a7db4 |
feat(phase-2): separate /profile (identity) from /career (master profile)
Phase 2 — Career/Profile separation. The master career profile is the source of
truth; identity and career data are now saved independently so neither wipes the
other. CV Builder deliberately not built yet.
Backend — PUT /auth/profile is now a partial update:
- null/omitted field -> unchanged; "" -> cleared; value -> set (trimmed).
- Email/UserName never cleared to empty (login identifiers).
This lets /profile save identity fields and /career save the master-profile
fields through the same endpoint without one nulling the other. 4 new tests
cover the data-integrity guarantees (identity save keeps the CV, career save
keeps identity, empty clears, null leaves).
Frontend:
- ProfilePage save payload is now scoped by careerOnly: /career sends only
{ profileCvText, profileCvStructureJson }, /profile sends only identity.
- CareerWorkspacePage: removed the inert "CV Builder" tab (careerView) — Phase 2
establishes the master profile only; the builder is Phase 4.
- Dropped the dead careerView prop.
- Updated the CV-save test to render career mode and assert identity is excluded.
Source-of-truth flip (CareerProfileService authoritative) stays deferred to F5
per the branch design; CareerProfileService keeps mirroring via its dual-write.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
992f89e619 |
feat: integrate Career Workspace foundation from feature/career-workspace
Recover the F1 Career Profile foundation + AI-workspace persistence from the unmerged feature/career-workspace branch, so Phase 2 builds on the documented, tested target state instead of re-deriving it. Foundation only — CV Builder commits (variants, ATS badge, rewrite diff) stay deferred per "do not build CV Builder yet". See docs/career-workspace-branch-assessment.md. Squashed from 3 branch commits ( |
||
|
|
eac34705e3 |
feat: Phase 0 foundation — Job entity, expanded pipeline, AI service lockdown, DateApplied history
Unblocks the documented core workflow and closes the AI-service exposure, without changing existing behaviour. Job/JobApplication split (additive; see ADR-002): - New Job entity (the opportunity) with owner-scoped query filter; nullable JobApplication.JobId FK. Nothing reads Job yet. - Migration AddJobEntityAndProspectStages, hand-edited to drop reconciler-owned tables the scaffolder re-emitted; verified against the real dev DB. Pipeline: 10 internal stages across three concerns kept separate — PipelineStage (workflow) / PipelineGroup (UI: NotApplied/Active/Closed) / PipelineCategory (analytics). Adds Saved/Interested/Preparing/Withdrawn; keeps Waiting and Ghosted. Kanban shows 3 grouped columns; cards keep a stage chip and full transitions; drag applies only safe transitions (never infers Ghosted/Withdrawn). DateApplied nullable + SavedAt. Cleared when leaving Applied so analytics stay accurate; the discarded date is preserved as an AppliedDateCleared JobEvent. AI service lockdown: no host port; private ai_internal network (backend is the only other member); X-Ai-Service-Token required on all non-/health endpoints; AI_SERVICE_TOKEN mandatory via compose. Verified backend-only against the live stack. Also carries two pre-existing working-tree files (views/ProfilePage.tsx, views/CareerWorkspacePage.tsx) so the tree is clean for the branch integration. Tests: +40 backend (247 total), +5 sidecar (16), +15 frontend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
706b3ec699 |
Merge branch 'feature/auth-2fa-security' into main
Auth/registration/account-security overhaul: per-account lockout, TOTP 2FA (RFC 6238) with recovery codes, trusted devices (30-day 2FA skip), configurable email verification enforcement, and server-tracked sessions (view/revoke/sign-out-others). Full security-settings UI and login/OAuth 2FA challenge step. # Conflicts: # JobTrackerApi/Services/StartupInitializationExtensions.cs |
||
|
|
fb04088d62 |
fix(auth): fix SQLite DateTimeOffset comparison crash in trusted-device checks
The sessions unit's live smoke test caught the same bug it fixed in SessionsController also present in TrustedDeviceService and TwoFactorController's device list: SQLite/Pomelo's EF Core provider cannot translate DateTimeOffset relational comparisons or ORDER BY to SQL, so IsDeviceTrustedAsync (the check that skips 2FA for a trusted browser) and ListTrustedDevices would 500 on real SQLite despite passing on EF's InMemory test provider. Same fix: equality-only in the DB query, expiry comparison and sort after materializing. |
||
|
|
c6918cbeea |
feat(auth): add server-tracked sessions with view/revoke
JWTs were previously fully stateless -- the token alone was the credential until its own expiry, with no way to list or kill a session server-side. Add a UserSession table alongside every JWT issued (AppSessionIssuer), embed its id as a "sid" claim, and check that claim against the DB on every "local" scheme request (Program.cs OnTokenValidated) so a session can actually be revoked before its JWT naturally expires. New /api/auth/sessions endpoints (list, revoke one, revoke-others) plus a Sessions card on the profile page. Fails closed on a missing "sid" claim: every JWT issued going forward has one, so a token without it is either pre-deploy (forces one re-login for already-signed-in users at deploy time, same additive-forward cost the 2FA/trusted-device work on this branch already paid) or forged. |
||
|
|
904f3a8ec8 |
feat(auth): add configurable email verification enforcement
Auth:RequireEmailVerification (default off) gates whether local register requires confirming email before login. OAuth new-user paths are untouched -- Google/Microsoft already assert a verified email. Adds verify-email and resend-verification-email endpoints, mirroring the existing reset-password enumeration-avoidance and rate-limiting patterns, plus a login-embedded resend affordance and a verify-email landing page on the frontend. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
b914630657 |
feat(auth): add trusted-device 30-day 2FA skip (backend)
Adds a "trust this device" option to the 2FA challenge: on success, mints a random token (only its SHA-256 hash is stored), sets it as a new httpOnly, Secure, SameSite=Strict cookie, and records a TrustedDevice row. AuthController checks that cookie for the exact signing-in user before gating on 2FA -- a mismatched user, expired, or revoked device falls through to the normal 2FA prompt, never errors. TwoFactorController also exposes list/revoke/revoke-all endpoints for managing trusted devices, scoped to the owning user. Schema added via the existing raw-SQL reconciler (SQLite + MySQL dialects), not EF migrations, matching this repo's established pattern. |
||
|
|
c68b49eda0 |
feat(auth): add per-account lockout and TOTP 2FA with recovery codes
Adds three layers of account-security hardening, all gated behind the existing SignInWithAppSessionAsync-equivalent (now AppSessionIssuer) so every sign-in path -- local, Google, Microsoft -- goes through the same lockout/2FA checks: - Per-account lockout: Identity's built-in lockout store (columns already provisioned, previously unused) is now wired up in AuthController.Login via IsLockedOutAsync/AccessFailedAsync/ResetAccessFailedCountAsync, 5 failed attempts / 15 min, same generic 401 as wrong-password to avoid enumeration. - RFC 6238 TOTP 2FA (Otp.NET) with QR-code setup (QRCoder, fully local/ offline) on a new TwoFactorController: setup requires password re-confirmation and returns a pending (unconfirmed) secret + QR; the secret is only persisted as active once verify-setup checks a real code. Secrets are encrypted at rest via the same IDataProtector pattern already used for Gmail/Microsoft OAuth refresh tokens. - Login/OAuth exchange now checks TwoFactorEnabled before issuing a real session. If enabled, it hands back an opaque, server-side (IMemoryCache) pending token via a new ITwoFactorPendingTokenService -- deliberately NOT a JWT, so it can never be presented as a bearer token to bypass the 2FA check on any other endpoint. Only POST /api/auth/2fa/challenge can redeem it, rate-limited at 5/5min (tighter than password login, since a 6-digit space is far more brute-forceable). - One-time recovery codes (10 per enable/regenerate, SHA-256-hashed at rest, shown once in plaintext) accepted in the same challenge endpoint as an alternative to a TOTP code. Schema: AspNetUsers gains TotpSecretEncrypted / TotpPendingSecretEncrypted / TotpEnabledAtUtc, plus a new TwoFactorRecoveryCodes table, added to both the SQLite and MySQL dialect blocks in the startup schema reconciler. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
717d1b9963 |
perf(db): add remaining hot-path indexes (status filter, correspondence/event FKs)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
3e09e74fc8 |
refactor(api): extract Gmail DTOs/parsers, batch N+1 loops
- Move inline DTOs to GmailDtos.cs, pure parse helpers to GmailParsing.cs - Batch per-message existence checks in CreateSuggestedJob/RefreshLinkedThreads - Remove redundant second pass in RelinkThread, reuse existing HashSet - Replace ToListAsync+scan with FirstOrDefaultAsync for GmailReviewDecisions lookups Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
4cfdc95b59 | refactor(api): extract ProfileCv DTOs, add missing AsNoTracking on reads | ||
|
|
ea6c3650f3 |
refactor(api): extract JobApplications DTOs and helpers, fix N+1 aggregation
- Move inline DTOs to JobApplicationDtos.cs, pure static helpers to JobApplicationHelpers.cs - GetStats aggregates server-side (COUNT/GROUP BY) instead of loading the full table - Cache RuleSettings via IMemoryCache, keyed per-user (RulesEngine.GetSettings falls back to per-user UserRuleSettings overrides, so a single global cache key would leak settings across users) - Add missing AsNoTracking() to read-only GET endpoints (GetAll, GetById, GetBoard, GetReminders, GetStatusSuggestion, GetMatchScore, GetCandidateFit, GetFocusPlan, GetInterviewPrep, GetReadiness) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
bd07876a41 |
fix(db): stop startup crash from MySQL composite-index key length
Prod was hard-down: InitializeJobTrackerAsync threw an unhandled
MySqlException ("Specified key was too long; max key length is 3072
bytes") while creating IX_JobApplications_OwnerUserId_FollowUpAt,
which crashed Program.Main before the app could start (surfaced to
users as a 500 on Google sign-in, but really affected every request).
Root cause: this reconciler assumes OwnerUserId is varchar(255), but
the live column was provisioned wider by an earlier EF migration,
close enough to the utf8mb4 3072-byte limit that pairing it with a
second column tips a composite index over.
Fix:
- Prefix-index OwnerUserId at 191 chars (safe under the legacy
767-byte-per-column limit, still far wider than the GUID-like
Identity ids actually stored) in every composite/unique index that
includes it, so index creation no longer depends on the column's
actual declared width.
- Wrap each CREATE INDEX in try/catch + LogWarning instead of letting
it propagate: a schema reconciler is best-effort and one failed
index must never crash startup, matching the existing non-fatal
pattern already used a few lines below for legacy-schema ownership
claims.
Backend build + full test suite (177 passing) verified green.
|
||
|
|
33d899c243 |
fix(auth): Google Sign-In audience mismatch + remove per-user accent color
Root cause of "Google authentication failed": appsettings.Development.json had Auth:GoogleClientId set to the literal placeholder "CHANGE_ME_GOOGLE_CLIENT_ID" while the frontend's .env.development had a real (already-public, already-committed) client ID -- every Google ID token's audience check failed against the backend's placeholder. Fixed by setting the same real client ID on both sides (a client ID is a public identifier, not a secret, safe to commit -- unlike a client secret). Also enabled Auth:AllowRegistration in dev so the existing Google-first self-serve-signup path (auto-create on unmatched verified email, auto-link on matching verified email -- built during Wave 7) is actually exercisable locally. Wired the previously-missing Auth__MicrosoftClientId / NEXT_PUBLIC_MICROSOFT_CLIENT_ID into docker-compose.yml/.env.example (distinct from the existing MICROSOFT_CLIENT_ID used for Outlook mail linking) -- Microsoft sign-in was never deployable, a leftover gap from when it was built. Fixed a stale env-var name in the Microsoft setup hint copy (still said REACT_APP_*, predates the Next.js migration). Removed the per-user accent color picker entirely: it was purely client-side (localStorage + theme.ts), never touched the backend/DB. theme.ts now hardcodes a single ACCENT constant; themePrefs.ts drops get/set/clearAccentColor; App.tsx and SettingsView.tsx drop the accentColor prop threading. Dead accent-related i18n keys removed from both locales. Consolidated Settings' "Account" tab (duplicated GoogleAuthCard, which already lives on the Profile page) into Profile: moved AuthStatusCard and EmailProviderConnections there alongside the existing Google/ Microsoft auth cards, so identity/account-linking lives in one place. Settings drops from 5 tabs to 4 and its General tab uses a consistent SectionCard layout instead of ad-hoc per-card styling. Verified: dotnet build/test (177/177) and npm build/test (57/57) both green; confirmed live against a running dev server that /auth/config now reports googleEnabled with the corrected client ID, Settings has no accent controls, and Profile shows the consolidated auth section. |
||
|
|
6903032c3b | Merge pull request 'feat(auth): Microsoft OAuth sign-in/link + self-serve signup via Google/Microsoft' (#22) from feature/wave7-oauth-signup into main | ||
|
|
3081d99355 |
feat(auth): Microsoft OAuth sign-in/link + self-serve signup via Google/Microsoft
Wave 7. Mirrors the existing Google ID-token-exchange pattern (Program.cs smart-scheme dispatch, JWT bearer scheme, AuthController exchange/link/ unlink endpoints, ApplicationUser fields, reconciler columns) for Microsoft Entra ID + personal accounts via the multi-tenant "common" endpoint. Google/Microsoft sign-in previously only worked for accounts already linked to an existing local user -- there was no way to actually sign up via OAuth. Both exchange endpoints now create a new user when no match is found and Auth:AllowRegistration is true, same gate as email/password registration. Frontend: new MicrosoftAuthCard (MSAL popup flow -- Microsoft has no vanilla-JS equivalent to Google's Identity Services script) wired into the login page's provider tabs and the profile page's account-linking section. REACT_APP_MICROSOFT_CLIENT_ID env var, Auth:MicrosoftClientId config gate on the backend. |
||
|
|
67ee3d7274 |
feat(ai): prompt-injection delimiters + synonym-aware match scoring
Wave 4 hardening. Wrap untrusted CV/job-description/instruction text in tools/summarizer prompts with explicit delimiters and an ignore-embedded-instructions rule, since JD text, recruiter emails, and free-text candidate background all flow into rewrite/normalize prompts unescaped today. Match score previously normalized synonyms (JS/Kubernetes/K8s/etc) only when scanning the job posting, not when checking the CV corpus, so a CV using an abbreviation the job spelled out never matched. SkillTagger.MatchesTag reuses the same synonym regex for both sides. |
||
|
|
b4fd5e2f96 |
fix(jobs): derive attachment checklist flags from actual Attachments
Backlog item 4 (Wave 3, first sub-item). HasResume/HasCoverLetter/HasPortfolio/ HasOtherAttachment were manually-editable checkboxes in EditJobDialog, completely independent of whether a file was actually attached -- classic drift: mark 'resume ready' by hand, later delete the resume attachment, flag stays stuck true forever. User confirmed (asked directly, since removing the manual-override capability is a product decision, not purely technical): make them fully computed from Attachments, no manual override. - AttachmentsController.RecomputeAttachmentFlagsAsync: the single place these four fields get written now, called after every attachment mutation (upload, delete, Purpose change) that could affect them. Deliberately kept as persisted columns (not [NotMapped] computed properties reading the Attachments navigation collection) -- ~15 query sites build JobApplication DTOs without .Include(Attachments), so a live-computed property would silently return false everywhere instead of throwing, the worst kind of bug. Recomputing at the one write funnel avoids touching any read path. - Removed HasResume/etc from CreateJobApplicationRequest/ UpdateJobApplicationRequest -- no longer client-settable. - EditJobDialog: removed the manual checkboxes, kept the (now genuinely accurate) read-only status chips. - AddJobModal: stopped sending has*-flags at job-creation time; the follow-up attachment upload call now sets them correctly via the same recompute path. Caught a real bug while testing this: the Purpose-change path recomputed before saving the Purpose change, so a fresh query missed the pending edit and the flags never updated. Fixed by committing the mutation before recomputing. 3 new backend tests (purpose-change sets flag, delete clears flag, non-primary purpose counts as "other"). 172/172 backend, 25/25 frontend suites (57 tests) green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
ab79072e52 |
refactor(gmail): extract DTOs and static helpers from GmailController
Backlog item 3 (Wave 2), GmailController slice. Pure mechanical extraction, no behaviour change: - GmailDtos.cs: the 26 inline record DTOs, moved to a partial-class file so every existing GmailController.XyzDto reference (tests included) keeps working unchanged. - GmailParsing.cs: the 8 pure static helpers (ApplySyncBoundary, LooksLikeJobRelatedThread, ToConfidence, ExtractFirstEmail/RecruiterName/ CompanyName/RoleFromSubject, BuildPopupHtml), same partial-class approach. GmailController.cs: 1200 -> 1022 lines. 169/169 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
6a43227315 |
perf(gmail): narrow review-decision lookup to the single ThreadId
Backlog item 2. CreateSuggestedJob, RelinkThread, and UnlinkThread each upserted exactly one GmailReviewDecision by ThreadId but loaded every review decision for the owner (GmailReviewDecisions.Where(OwnerUserId == x).ToList()) just to linear-scan for the one match. Replaced with FirstOrDefaultAsync filtered on both OwnerUserId and ThreadId, and added a single-row UpsertReviewDecision overload alongside the existing dictionary-based one (still used by the review-queue endpoints, which genuinely need every decision at once to render the queue). 169/169 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
a9a0ddecbc |
chore(db): resync stale EF ModelSnapshot + fix fresh-DB schema gap
Backlog item 1. The committed ModelSnapshot was empty/stale (21 lines, no entities) -- `dotnet ef migrations add` scaffolded the whole database from scratch against it, including the ASP.NET Identity tables, which have never been created by a real EF migration in this repo (always provisioned via the raw-SQL reconciler in StartupInitializationExtensions.cs -- see EnsureIdentityTables' own comment). Applying that diff for real would throw "table/column already exists" on every environment. Fix: added migration 20260711181039_SyncModelSnapshot with an intentionally empty Up()/Down() (see its doc comment) -- it only records itself in __EFMigrationsHistory and regenerates the snapshot to match the live model, so `dotnet ef migrations add` produces a real diff for the next schema change instead of the whole database again. Verified zero side effects against a copy of the dev DB (only inserts one history row) and against a fresh empty DB (full migration + reconciler chain runs clean). That fresh-DB verification surfaced a real, previously-undiscovered bug: EnsureColumn/EnsureMySqlColumn calls for JobApplications/Correspondences/ Companies/Attachments ad-hoc columns all no-op on a truly fresh database (the tables don't exist yet -- Migrate() creates them afterward), so a brand-new deployment's first boot would be missing dozens of columns (LastReminderEmailSentAt, RecruiterMessageDraft, salary fields, Correspondence Provider/Subject/Channel/etc.) until the next restart. Also caught: my own b4 change (Correspondence.Provider backfill, already merged) had the same unguarded-on-fresh-DB bug in isolation. Fixed by promoting the schema-reconciliation helpers (Exec/HasTable/ HasColumn/EnsureColumn and their MySQL equivalents) from local functions to class-level statics, extracting the ad-hoc-column blocks into ReconcileCoreAppColumns/ReconcileCoreAppColumnsMySql, and calling them a second time right after Migrate() succeeds (reusing the connection already opened for the CoreSchemaReady check) -- idempotent, so free on every boot except the first one, where it's now required. No inline logic changed, pure extraction + one additional call site. Also added Microsoft.EntityFrameworkCore.Design to JobTrackerApi.csproj (dotnet-ef tooling requires it on the startup project since EF Core 6+; previously only referenced by JobTrackerBackend, where the DbContext lives). 169/169 backend tests green. Verified live: full app boot against both a fresh empty SQLite DB and a copy of the populated dev DB, both clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
cb2715c323 |
feat(email): add Correspondence.Provider discriminator
b4 of the multi-provider email roadmap. The manual/free-text correspondence entry path already existed (CorrespondenceController.Create) -- this slice was narrower than the roadmap wording suggests: tag every Correspondence row with which provider it came from (gmail | manual today; microsoft | imap once those providers grow an import-into-Correspondence path of their own), not build a new endpoint. - Correspondence.Provider (nullable string), reconciled via the existing EnsureColumn pattern (SQLite + MySQL). - Idempotent backfill: rows with an ExternalThreadId (historically only ever written by Gmail import) get 'gmail'; everything else gets 'manual'. - GmailController.ImportSingleMessageAsync now tags Provider = "gmail". - CorrespondenceController.Create now tags Provider = "manual". - Both write sites use a fixed literal, not request input -- no injection surface introduced. Backfill SQL is static, no interpolation. 148/148 green (147 existing + 1 new CorrespondenceControllerTests; the GmailController import test gained a Provider assertion in place). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
a8e2f4dc4a |
feat(email): add ImapProvider (generic IMAP for unsupported providers)
b3 of the multi-provider email roadmap. Adds ImapConnection model + table (reconciler pattern, SQLite+MySQL), ImapService (MailKit-backed IMAP client), ImapProvider implementing the existing IEmailProvider contract unchanged, and ImapController for credential-based connect (no OAuth — user supplies host/username/password directly, verified by a live connect before storage). Scope, documented inline with ponytail: comments: - INBOX only, no multi-folder support. - Thread grouping approximates the References/In-Reply-To chain root rather than the IMAP THREAD extension, which not every server implements. - External message ids are IMAP UIDs, scoped to the connection's current UIDVALIDITY. Security: ran the security-audit skill against this diff (credential handling + arbitrary-host connect is exactly the class of change the standing security gate exists for). Found and fixed a real SSRF: the connect endpoint let an authenticated user point the server at an arbitrary host:port with no internal-range check, and connect-vs-auth failure was distinguishable to the caller -- together a working oracle to fingerprint internal services (loopback/RFC1918/link-local/cloud metadata) from the server's network position. Fixed with EnsureHostIsExternalAsync (DNS-resolve + reject internal ranges, re-checked on every reconnect to close the DNS-rebinding gap) and a single generic failure message that no longer distinguishes connect vs auth failure. 7 regression tests added. Dependency: MailKit 4.17.0 (MIT license) on JobTrackerBackend.csproj -- stdlib has no IMAP client; hand-rolling IMAP4rev1 (TLS, SASL, MIME parsing) would be a large, security-sensitive protocol implementation nobody asked for, so this is the correct dependency, not a stdlib substitute. 168/168 green (161 existing + 7 new SSRF regression tests; the earlier 14 IMAP feature tests are included in the 161). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
cacad5cc94 |
feat(email): add MicrosoftGraphProvider (Outlook/365 via Graph OAuth)
b2 of the multi-provider email roadmap. Mirrors the Gmail provider's shape end-to-end so the two stay structurally interchangeable: - MicrosoftGraphConnection model + table (reconciler pattern, SQLite+MySQL, same shape as GmailConnection: encrypted refresh/access token, sync state). - MicrosoftGraphOAuthService: auth-code + offline-access flow against login.microsoftonline.com, encrypted token storage via IDataProtector, message search/thread/detail fetch against Microsoft Graph (conversationId stands in for Gmail's threadId), attachment listing. - MicrosoftGraphProvider implements IEmailProvider — no contract changes; the existing seam was already provider-neutral. - MicrosoftGraphController: connect-url/oauth/callback/status/disconnect, mirrors GmailController's OAuth surface exactly (including the popup postMessage handshake). Job-matching/review endpoints stay Gmail-only for now, per the roadmap — generalising those needs the frontend provider picker work, not this slice. - Registered in DI + IEmailProviderRegistry (multi-registration of IEmailProvider, resolved by ProviderKey). - Config: Microsoft:ClientId/ClientSecret/TenantId/RedirectUri, wired through docker-compose.yml + .env.example alongside the existing Google:Gmail* keys. - Tests: MicrosoftGraphControllerTests (OAuth lifecycle) + MicrosoftGraphProviderTests (DTO mapping onto the neutral contract). 147/147 green (135 existing + 12 new). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
9cb99a7ba7 |
refactor(gmail): route message import through IEmailProvider
ImportSingleMessageAsync now fetches the message + connection through the provider-neutral seam (Email.GetMessageAsync/GetConnectionAsync), mapping the neutral ExternalAttachmentId onto CorrespondenceAttachmentMetadata. The controller's import path no longer touches Gmail directly. OAuth lifecycle, the rich connection-status DTO, and Gmail candidate ranking stay on IGmailOAuthService until a second provider (Microsoft/IMAP) forces the contract shape. 135/135 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
9febc2b22f |
refactor(gmail): route controller read paths through IEmailProvider
GmailController now resolves the "gmail" provider from IEmailProviderRegistry and uses the provider-neutral seam for its read paths — message search (SearchAsync) and thread listing (ListThreadMessagesAsync) across ImportThread, RelinkThread, CreateSuggestedJob, RefreshLinkedThreads and the messages endpoint. OAuth (connect/callback), connection status and Gmail-specific candidate ranking stay on IGmailOAuthService until they are generalised. An optional constructor param keeps direct construction (tests) working via a fallback single-Gmail registry, so the mocked Gmail service is exercised through GmailProvider. Behaviour is preserved (neutral DTOs mirror the Gmail shapes). This makes the seam a real consumer and sets up MicrosoftGraphProvider / ImapProvider / a manual free-text provider to slot in next. Build clean; backend suite 135/135 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
63e0300788 |
fix(deploy): retry publish after clearing NuGet caches on NU3008
The prod deploy failed restoring a transitive package (Microsoft.CodeAnalysis.Workspaces.Common) with NU3008 "package integrity check failed / has changed since it was signed" — a transient corrupted download on the build host, not a code change. Wrap the backend `dotnet publish` so that on any failure it clears all NuGet caches and retries once, re-downloading the package fresh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |