Aller au contenu

WonderWork — Data Model

Source of truth for the database schema. Decisions documented with rationale. Generated after full structural analysis — March 2026.


Entity Relationship Diagram

erDiagram
    User ||--|| Candidate : "CTI subtype"
    User ||--|| Recruiter : "CTI subtype"
    User ||--|| Admin : "CTI subtype"
    User ||--o{ RefreshToken : "has"
    User ||--o{ EmailVerificationToken : "has"

    Candidate ||--|| Passport : "has one"
    Candidate ||--o{ Bubble : "saved searches (≤20)"
    Passport ||--o{ PassportTargetJob : "Q7 — up to 3"
    Passport ||--o{ PassportSkill : "Q11 inline + Q12 review"
    Passport ||--o{ PassportPreferredCity : "Q13 — up to 4"
    Passport ||--o{ PassportQualification : "Q10 — STI"
    Passport ||--o{ PassportProfessionalExperience : "Q10 — employment history"
    Passport ||--o{ PassportPersonalProject : "Q10 — side projects"
    Passport ||--o{ PassportCultureValue : "Q14 — desired values"

    City ||--o{ Passport : "city of residence"
    City ||--o{ PassportPreferredCity : "preferred city"
    City ||--o{ PassportProfessionalExperience : "experience city"
    City ||--o{ PassportQualification : "institution city"

    SkillFamily ||--o{ Skill : "groups (thematic family)"
    Skill ||--o{ PassportSkill : "referenced by"
    Skill ||--o{ JobOfferSkill : "referenced by"
    Skill ||--o{ RomeJobSkill : "suggested for"
    RomeJob ||--o{ RomeJobSkill : "requires"
    RomeJob ||--o{ RomeJobAppellation : "also known as"

    Company ||--o{ Recruiter : "employs"
    Company ||--o{ JobOffer : "posts"
    Recruiter ||--o{ JobOffer : "creates"
    Recruiter ||--o{ Recruiter : "invites"

    JobOffer ||--o{ JobOfferSkill : "requires"
    JobOffer ||--o{ JobOfferStage : "defines (recruitment pipeline)"

    CultureValue ||--o{ CompanyCultureValue : "adopted by"
    Company ||--o{ CompanyCultureValue : "culture"
    CultureValue ||--o{ PassportCultureValue : "desired by"

    Candidate ||--o{ Application : "submits"
    JobOffer ||--o{ Application : "receives"
    Application }o--o| JobOfferStage : "current stage"
    Application ||--o{ ApplicationEvent : "events"
    JobOfferStage ||--o{ ApplicationEvent : "referenced in"
    Application ||--o{ ApplicationStageNotification : "scheduling / notice"
    JobOfferStage ||--o{ ApplicationStageNotification : "referenced in"
    ApplicationStageNotification ||--o{ InterviewFeedback : "post-interview feedback"

    Candidate ||--o{ MatchScore : "scored against"
    JobOffer ||--o{ MatchScore : "scored against"

Entities

users

Abstract base table for all authenticated users — Class Table Inheritance (CTI). Authentication and identity fields live here. Domain-specific fields live in candidates and recruiters (the admins subtype carries no extra domain fields).

Column Type Notes
id UUID PK
type VARCHAR(20) CTI discriminator: candidate / recruiter / admin
email VARCHAR(255) UNIQUE
password VARCHAR(255) nullable null for future OAuth users
email_verified_at TIMESTAMPTZ nullable null = unverified
oauth_provider VARCHAR(50) nullable anticipated: google / linkedin — not implemented
oauth_id VARCHAR(255) nullable anticipated — not implemented
created_at TIMESTAMPTZ
updated_at TIMESTAMPTZ
last_login_at TIMESTAMPTZ nullable updated on every successful login
deleted_at TIMESTAMPTZ nullable soft delete — RGPD

Why CTI over two independent entities? Candidate and Recruiter share auth logic (password hashing, JWT, email verification). CTI keeps a single Symfony UserProvider and a single firewall. Domain-specific data stays in clean, non-sparse subclass tables.

Why not a single users table with a role column? Candidate and Recruiter have fundamentally different domain data — merging them would produce a sparse, semantically ambiguous table.


refresh_tokens

Issued by gesdinet/jwt-refresh-token-bundle. One row per active session. Rotated on every use — the old token is deleted and a new one issued. If a previously rotated token is submitted, all tokens for that user are immediately revoked (theft detection).

Column Type Notes
id INT AUTO_INCREMENT PK bundle default
refresh_token VARCHAR(128) UNIQUE opaque token
username VARCHAR(255) user email — identifier used by Symfony
valid TIMESTAMPTZ expiry — 30 days (candidate) or session (recruiter)

email_verification_tokens

One-time use tokens sent by email to verify account ownership. Linked to User (not Candidate) — works for both roles. Multiple tokens may exist per user (via resend), but only the latest is active — a resend revokes all previous tokens by setting used_at.

Column Type Notes
id UUID PK
user_id UUID FK → users
token VARCHAR(64) UNIQUE crypto-random hex string
expires_at TIMESTAMPTZ created_at + 24 hours
used_at TIMESTAMPTZ nullable null = active, set when consumed or revoked
created_at TIMESTAMPTZ

candidates

Domain-specific data only. Authentication fields inherited from users via CTI.

Column Type Notes
id UUID PK FK → users shared PK, CTI pattern
first_name VARCHAR(100)
last_name VARCHAR(100)

passports

One passport per candidate. Contains all onboarding answers that are scalar or arrays. Structured sub-data lives in dedicated tables.

Column Type Notes
id UUID PK
candidate_id UUID FK UNIQUE 1:1 with candidates
has_experiences BOOLEAN nullable null = skipped, false = "I have none", true = has entries — UX nudging only
has_qualifications BOOLEAN nullable null = skipped, false = "I have none", true = has entries — UX nudging only
is_gender_visible BOOLEAN DEFAULT false Q2 privacy toggle
is_birth_date_visible BOOLEAN DEFAULT false Q3 privacy toggle
is_nationality_visible BOOLEAN DEFAULT false Q4 privacy toggle
is_city_visible BOOLEAN DEFAULT false Q5 privacy toggle
gender VARCHAR(50) nullable Q2 — free text for "Other"
birth_date DATE nullable Q3
nationality_id UUID FK → nationalities nullable Q4
city_of_residence_id INTEGER FK → cities nullable Q5 — GeoNames city reference
current_status VARCHAR(50) nullable Q6 — enum: student / active / unemployed / reconverting / freelance / entrepreneur / retired
market_positioning VARCHAR(50) nullable Q6 — enum: actively_searching / open / not_looking
work_mode VARCHAR(20) nullable Q13 — single value: remote / hybrid / on_site
preferred_company_sizes JSON nullable Q14 — array of: startup / small / medium / large / enterprise. null = indifférent. Bonus-only in BLOC 4 (no penalty for mismatch)
preferred_sectors JSON nullable Q14 — max 5
availability VARCHAR(20) nullable Q15 — now / lt_2w / lt_1m / lt_2m / lt_3m / 6m_plus
minimum_salary INTEGER nullable Q15 — brut €/year, cand.min for BLOC 5
profile_photo_url VARCHAR(500) nullable Q17
is_reconverting BOOLEAN DEFAULT false Computed — ROME distance (most recent is_current experience ROME vs Q7 target jobs)
is_open_to_relocation BOOLEAN DEFAULT false Q13 — willing to relocate; used in BLOC 5 location scoring
created_at TIMESTAMPTZ
updated_at TIMESTAMPTZ

Why separate from candidates? The passport has 25+ columns, a distinct lifecycle (privacy settings, generation state), and is invisible to recruiters for deleted accounts. Keeping it separate preserves candidates as a lean auth table.

No seniority_level column. Seniority is computed at scoring time from total professional experience years (employment only, overlapping periods merged). Thresholds: 0–2 → junior, 2–5 → mid, 5–10 → senior, 10+ → executive. This avoids self-declaration bias and removes one onboarding question.

No current_job_rome_code / current_job_label columns. The current job is the passport_professional_experiences entry with is_current = true. The matching algorithm extracts it from the experiences collection — no denormalized cache needed. If no current experience exists, seniority defaults to null (neutral score 25/50 in BLOC 3).

Arrays (JSON): preferred_sectors stored as JSON (JSONB in PostgreSQL) — simpler than native TEXT[] with equivalent GIN index support. Culture values are in passport_culture_values junction table for controlled vocabulary matching.

No q10_completed / q11_completed flags: completion is computed on the fly from COUNT(passport_qualifications) and COUNT(passport_professional_experiences) — storing it would be denormalized data with a synchronization risk.


passport_target_jobs

Q7 — up to 3 target jobs per passport, ROME-structured.

Column Type Notes
id UUID PK
passport_id UUID FK
rome_code VARCHAR(6) indexed — used in BLOC 1
label VARCHAR(150) display label
contract_types JSONB NOT NULL DEFAULT '[]' accepted contract types (ContractType[]). Empty = open to all. Drives the matching contract-fit penalty on the best-match target job

passport_preferred_cities

Q13 — up to 4 preferred work cities per passport.

Column Type Notes
id UUID PK
passport_id UUID FK
city_id INTEGER FK → cities GeoNames city reference — city name, country, coordinates via JOIN
position SMALLINT NOT NULL 1 = most preferred. Order defines priority — no separate preference level

No denormalized city columns. City name, country code, and coordinates are accessed via JOIN to cities. Single source of truth — no label/country desync risk.

Position-based priority. The candidate ranks cities by order. Position 1 is the most desired. Used in BLOC 5 location matching — score = 45 - (position - 1) × 4 (position 1 → 45 pts, position 4 → 33 pts). Reordering is done via ↑/↓ buttons in the UI. Cities are conditional on work mode — only shown if candidate selects on_site or hybrid (not remote-only).


skills

Controlled vocabulary of skills, wholly owned by WonderWork (proprietary catalogue that replaced the raw France Travail import). No candidate or recruiter additions — the catalog is managed by the WonderWork team. ROME jobs stay the interoperability pivot: skills are linked to ROME jobs via rome_job_skills, but the skills themselves are no longer ROME-sourced.

Column Type Notes
id UUID PK
label VARCHAR(150) Normalized display label
code VARCHAR(10) nullable Stable identifier — ISO 639-1 for language skills (fr, en), null for all other skills
category VARCHAR(20) enum hard_skill / soft_skill / language (PHP SkillCategory enum via Doctrine enumType)
family_id UUID FK → skill_families, nullable Thematic family the skill belongs to (one family per skill). Null until assigned by the catalogue generation
search_aliases JSON (array of strings) Search synonyms (e.g. ["ReactJS", "React.js"] for React), powering skill search alongside the label. Not exposed in the API
description TEXT nullable Description of the skill
created_at TIMESTAMPTZ

Unique constraint: functional unique index on (LOWER(label), category) — the canonical key of the catalogue (case-insensitive on the label).

Category collapse. The former competence (savoir-faire) and knowledge (savoir) values were merged into hard_skill: the distinction was ROME heritage, used nowhere in the matching, and inconsistent in the data. The thematic family now carries the visual grouping, orthogonally to the category (nature) axis. soft_skill and language are unchanged.

Language skills: Skills with category = 'language' are seeded from shared/data/languages.json. The code column stores the ISO 639-1 code used by the frontend for flag display. Languages are referenced by passport_skills with a CEFR proficiency value — there is no separate passport_languages table.

Why a controlled vocabulary? Free-text skill names make matching unreliable ("React" vs "ReactJS" vs "React.js"). A shared referential enables exact FK matching in BLOC 2 and BLOC 6, removing the need for semantic similarity computation. search_aliases absorbs the wording variants at search time without polluting the canonical label.


skill_families

Thematic families used to navigate and group the skill catalogue in the selection UX. A family is not a selection unit — the leaf skill stays the unit of selection and matching; the family only groups.

Column Type Notes
id UUID PK
label VARCHAR(150) Family display label
selection_mode VARCHAR enum bundle (coherent set owned as a block, opt-in with an optional select-all) / picklist (substitutable alternatives, strict opt-in)
created_at TIMESTAMPTZ

Why a selection mode? The UX distinguishes families you possess as a whole (a trade's base gestures → bundle) from menus of substitutable alternatives (languages, tools → picklist, never bulk-selected). Populating families onto skills is out of scope of the data-model rework and handled by the catalogue generation.


cities

GeoNames cities dataset — ~140 000 cities with population > 1 000, covering all countries. Seeded offline from the GeoNames cities1000 export. Used for city autocomplete across the entire platform.

Column Type Notes
geonames_id INTEGER PK GeoNames stable identifier
name VARCHAR(200) City name — indexed with pg_trgm for autocomplete
region VARCHAR(200) nullable First administrative division — region (FR), state (US), Land (DE). Resolved from admin1CodesASCII.txt at import time. Displayed in dropdown: "Paris, Île-de-France, FR"
country_code VARCHAR(2) ISO 3166-1 alpha-2
population INTEGER Used for relevance sorting in autocomplete results (bigger cities first)

Index: GIN trigram index on name via pg_trgm extension — enables fast ILIKE '%par%' queries in < 5ms.

API endpoint: GET /v1/cities?q=Par&country=FR — debounce 300ms on frontend, returns top 10 results sorted by population descending.

Referenced by: passports.city_of_residence_id, passport_preferred_cities.city_id, passport_professional_experiences.city_id. All city data (name, country, coordinates) is accessed via JOIN — no denormalized labels anywhere.

Why local dataset over external API? Sovereign (no US CLOUD Act exposure), free, performant, scales to any country without API key or rate limits. Same pattern as the ROME catalogue: reference data imported locally, served by our own API.


rome_jobs

ROME 4.0 fiches métiers — the official French occupational referential maintained by France Travail. Imported locally via batch Command from the ROME 4.0 API. Used by candidates to select their target job and by recruiters to classify their job offers.

Column Type Notes
code VARCHAR(6) PK ROME code (e.g. M1805)
label VARCHAR(200) Official label (e.g. "Développeur / Développeuse informatique")
skills_curated_at TIMESTAMPTZ nullable Set when the job's skills have been regenerated into the proprietary catalogue. Null = not yet curated (still on ROME-imported skills)

~1 584 entries. Updated a few times per year when France Travail publishes new fiches.

Progressive catalogue rollout. skills_curated_at marks which jobs have proprietary curated skills. The /v1/appellations autocomplete only returns curated jobs (skills_curated_at IS NOT NULL), so the platform only ever exposes the new skill catalogue. Non-curated jobs keep their ROME-imported skills inert until the ROME-removal chore purges them. Progress = count(*) WHERE skills_curated_at IS NOT NULL.


rome_job_appellations

Variant names for ROME jobs — enables autocomplete when a candidate or recruiter searches for a job by name. One ROME job has 5-15 appellations on average (e.g. M1805 → "Développeur web", "Développeur fullstack", "Ingénieur logiciel", etc.).

Column Type Notes
code VARCHAR(10) PK OGR code (France Travail identifier)
label VARCHAR(250) Appellation label
rome_job_code VARCHAR(6) FK → rome_jobs Parent ROME code

~13 000 entries.


rome_job_skills

Junction table — WonderWork skills associated to a ROME job, curated by the WonderWork team. The rome in the name qualifies the job (which stays ROME-based), not the skill. Used to pre-fill the skill picker in the job offer creation and passport completion forms when a ROME code is selected.

Column Type Notes
skill_id UUID FK → skills
rome_job_code VARCHAR(6) FK → rome_jobs ROME code
PRIMARY KEY (skill_id, rome_job_code)

Not resolved at runtime. The catalog is imported via batch Command — no live API call on form load.


passport_skills

Q14 — skills selected from the WonderWork skill catalog.

Column Type Notes
id UUID PK
passport_id UUID FK
skill_id UUID FK → skills controlled vocabulary — replaces free-text skill_name
proficiency VARCHAR(10) nullable a1 / a2 / b1 / b2 / c1 / c2 / native — only set when skill.category = 'language', null otherwise
is_top BOOLEAN DEFAULT false boost ×1.20 in BLOC 2 — Top 5 for technical skills, Top 3 for soft skills

No skill level for non-language skills. Proficiency self-assessment is unreliable and biased. Binary presence + TOP flag is more honest and sufficient for matching. Only language skills use proficiency (CEFR standard A1–C2 + native).

Two TOP designations. Technical skills (hard_skill): Top 5, no selection cap. Soft skills (savoir-être): max 5 selected, Top 3 among them. Selected on separate screens (Q11 for technical, Q12 for soft skills). Displayed as two distinct sections on the Employment Passport. The ×1.20 boost in BLOC 2 applies to both types. Caps enforced in both frontend (disabled UI) and backend (service-level validation).

Languages merged here. PassportLanguage is removed — language skills are passport_skills entries with skill.category = 'language'. BLOC 6 filters on this category at computation time.

No hard cap on total skills. Candidates can select as many skills as they want. The matching algorithm compares against the job offer's required skills — having more candidate skills only increases matching breadth, not score inflation. The recruiter-side filtering (via the algo) handles relevance.

Why no free-text label? skill_id FK into the skills catalog enables exact matching in BLOC 2 — no semantic similarity computation needed.


passport_qualifications

Q18 — academic degrees, self-taught experiences, and certifications. Single Table Inheritance.

Column Type Notes
id UUID PK
passport_id UUID FK
type VARCHAR(20) discriminator: academic / self_taught / certification
education_level VARCHAR(20) nullable academic only — lycee / bac / bac_2 / bac_3 / bac_4 / bac_5 / bac_8 / other
field_of_study VARCHAR(150) nullable academic only
diploma_name VARCHAR(200) nullable academic only
institution_name VARCHAR(200) nullable academic only
year SMALLINT NOT NULL Year of obtention/completion — all types
domain VARCHAR(150) nullable self_taught only
learning_methods JSON nullable self_taught only — array of: online_courses / personal_projects / books / mentoring / open_source / bootcamp / other
certification_name VARCHAR(200) nullable certification only

Why STI over CTI? Three types with mostly shared fields. NULLs are limited and stable over time. No algo query on these fields (display data only). CTI would add 3 unnecessary JOINs for a simple list render.

Display only: PassportQualification data is never used in the matching algorithm. It is displayed on the Employment Passport for recruiter review (credibility signal — academic background and certifications).


passport_professional_experiences

Q10 — employment history. Each entry is a professional experience with dates, company, and ROME code.

Column Type Notes
id UUID PK
passport_id UUID FK
rome_code VARCHAR(6) NOT NULL indexed — ROME catalogue selection, feeds BLOC 3 + inline skills
job_title VARCHAR(150) NOT NULL position held
company_name VARCHAR(150) NOT NULL employer or client name (label adapts by contractType in UI)
sector VARCHAR(150) nullable sector of activity
city_id INTEGER FK → cities nullable GeoNames city reference (null if remote)
contract_type VARCHAR(30) NOT NULL cdi / cdd / interim / apprentissage / internship / freelance
start_year SMALLINT NOT NULL
start_month SMALLINT NOT NULL 1–12
end_year SMALLINT nullable null if is_current = true
end_month SMALLINT nullable null if is_current = true
is_current BOOLEAN NOT NULL drives seniority and reconversion detection
is_remote BOOLEAN NOT NULL 100% remote — when true, city_id is null
created_at TIMESTAMPTZ

Why year/month smallints instead of DATE? Candidates typically know the month and year, not the exact day. Storing DATE with a forced day convention (e.g. first of month) is a hidden contract — any consumer could misread it as a precise date. Two SMALLINT columns are semantically honest and avoid the convention risk.

Algo usage: rome_code + start_year/start_month/end_year/end_month/is_current are used in BLOC 3 (relevant experience years + seniority level), and to detect reconversion intent (ROME distance between most recent is_current entry and Q7 target titles).

Current job = is_current experience. There is no denormalized current_job_rome_code on passports. The matching algorithm extracts the current job from the experience marked is_current = true.


passport_personal_projects

Q10 — side projects. Demonstrative value (what was built, in which skills domain), not temporal. No dates.

Column Type Notes
id UUID PK
passport_id UUID FK
rome_code VARCHAR(6) NOT NULL required — feeds inline skill suggestions during onboarding. Also serves as the project's domain context (the ROME label is displayed to recruiters)
project_name VARCHAR(200) NOT NULL
description TEXT nullable brief project description — displayed to recruiters
link VARCHAR(500) nullable GitHub, portfolio, live app
created_at TIMESTAMPTZ

Why separate from professional experiences? The two entities share almost no fields (only rome_code). A single table would have 11+ nullable columns unused for each type. Separate tables are self-documenting, require no type discriminator, and eliminate all conditional logic in the service layer, matching algorithm, and validation. Personal projects are not scored in BLOC 3 — their rome_code serves only for inline skill suggestions (linking the project to relevant technical skills via BLOC 2).

Why no domain column? The ROME code already captures the project's professional domain. A separate text field would duplicate this information. The ROME label (resolved from the code) is displayed to recruiters as context.


companies

Column Type Notes
id UUID PK
name VARCHAR(200)
logo_url VARCHAR(500) nullable
size VARCHAR(20) nullable startup / small / medium / large / enterprise
sector VARCHAR(150) nullable
description TEXT nullable company presentation — displayed on company page
website VARCHAR(500) nullable
founded_year SMALLINT nullable
employee_count INTEGER nullable actual headcount — may differ from size enum
created_at TIMESTAMPTZ
updated_at TIMESTAMPTZ
deleted_at TIMESTAMPTZ nullable soft delete

recruiters

Domain-specific data only. Authentication fields inherited from users via CTI.

Column Type Notes
id UUID PK FK → users shared PK, CTI pattern
company_id UUID FK
first_name VARCHAR(100)
last_name VARCHAR(100)
role VARCHAR(20) owner / recruiter
invited_by_id UUID FK nullable self-ref — who sent the invitation
invitation_token VARCHAR(255) nullable tokenized invite link
invitation_expires_at TIMESTAMPTZ nullable 48h expiry

admins

WonderWork team back-office accounts. Third CTI subtype of users, alongside candidates and recruiters. Carries no domain-specific columns: everything an admin needs (identity, credentials) already lives in users. The table exists only to complete the CTI shape (one row per admin, sharing the users PK).

Column Type Notes
id UUID PK FK → users shared PK, CTI pattern

Admin accounts are provisioned out of band (app:admin:create), never self-registered. Role ROLE_ADMIN is derived from the subtype, not stored. Sign-in is isolated on POST /v1/admin/auth/login (session only).


security_events

Append-only security audit log, transverse to every user type (candidate, recruiter, admin). One row per security-sensitive action: sign-in success/failure, sign-out, password change, profile photo change, personal-data export, account deletion request, account purge, and an admin consultation of a profile (admin_viewed_user, actor = admin, subject = the viewed account). Written through a single recorder, never updated, removed only by the one-year retention purge (app:security-events:purge, daily). Carries no raw personal data.

Column Type Notes
id UUID PK
occurred_at TIMESTAMPTZ when the action happened
type VARCHAR(40) SecurityEventType discriminator
actor_id UUID FK → users nullable, ON DELETE SET NULL who acted, null = the system
subject_id UUID FK → users nullable, ON DELETE SET NULL the account concerned, nulled when that account is purged
subject_role_snapshot VARCHAR(20) nullable frozen role of the subject (candidate/recruiter/admin), survives the account purge; null when no subject
ip_address VARCHAR(45) nullable source IP (IPv6-capable), the only network metadata besides the user-agent
user_agent VARCHAR(512) nullable source user-agent, truncated
metadata JSON nullable minimal non-personal context (e.g. a failure reason), never a raw name, email, or profile content

Failed sign-in is enumeration-safe: the raw submitted email is never stored, and subject_id links to an account only when the submitted identifier matched an existing one. Not CASCADE: actor/subject use ON DELETE SET NULL so an event survives a purged account, de-identified, retaining only its role snapshot. Indexed on (subject_id, occurred_at) (per-account reads) and (occurred_at) (the retention purge). All writes go through the single SecurityEventRecorder, the shared append-only recording entry point reusable by future audit families.


job_offers

Column Type Notes
id UUID PK
company_id UUID FK
recruiter_id UUID FK created by
rome_code VARCHAR(6) ROME job title — required, indexed, used in BLOC 1
rome_label VARCHAR(150) display label
location VARCHAR(200) display label — "Paris 15e, Île-de-France"
location_geonames_id INTEGER nullable GeoNames ID — used in BLOC 5 location matching; null if not geocoded
contract_type VARCHAR(30) CDI / CDD / interim / apprentissage / internship / freelance
work_time VARCHAR(20) full_time / part_time
work_arrangement VARCHAR(20) remote / hybrid / on_site
target_availability VARCHAR(20) ASAP / lt_1m / lt_3m / flexible — backed by JobOfferTargetAvailability enum (distinct from candidate-side Availability — different semantics)
experience_level VARCHAR(20) nullable junior / mid / senior / lead / director / executive — BLOC 3, backed by ExperienceLevel enum
required_education_level VARCHAR(20) nullable high_school / bac / bac_2…bac_8 / other — backed by EducationLevel enum. NULL = no requirement. Display-only for now; matching effect delivered with the matching redesign (B2)
contract_duration VARCHAR(100) nullable CDD/interim/internship only
salary_min INTEGER nullable display only
salary_max INTEGER nullable offer.max used in BLOC 5 salary matching
display_salary BOOLEAN DEFAULT true hide salary from candidates when false
description TEXT
status VARCHAR(20) draft / published / closed
published_at TIMESTAMPTZ nullable
created_at TIMESTAMPTZ
updated_at TIMESTAMPTZ
deleted_at TIMESTAMPTZ nullable soft delete

job_offer_skills

Required skills per offer. Feeds BLOC 2 and BLOC 6 of the matching algorithm.

Column Type Notes
id UUID PK
job_offer_id UUID FK
skill_id UUID FK → skills controlled vocabulary — replaces free-text skill_name
importance VARCHAR(20) nullable critical / important / nice_to_have — NULL when skill.category = 'language' (handled by BLOC 6 separately, not BLOC 2); required for all other categories. Validated at write time on the Input DTO.
level_required VARCHAR(20) nullable beginner / intermediate / advanced / expert — null when skill.category = 'language'. Consumed by MatchingBloc2 via mapping of the candidate's is_top flag (top → expert/advanced equivalent, non-top → intermediate equivalent). When null, no level filter is applied for that skill.
proficiency_required VARCHAR(10) nullable a1 / a2 / b1 / b2 / c1 / c2 / native — null when skill.category != 'language'

Languages merged here. JobOfferLanguage is removed (entity, table, and references) — required languages are job_offer_skills entries with skill.category = 'language', proficiency_required set, and importance left NULL. BLOC 6 filters on this category.

Exact FK matching. skill_id enables deterministic comparison with passport_skills.skill_id — no semantic similarity needed. Importance levels weight the scoring penalty for absence in BLOC 2 (critical = weight 3, important = weight 2, nice_to_have = weight 1). Languages are scored separately in BLOC 6 by CEFR level — importance does not apply.


culture_values

Controlled vocabulary of company culture values. Seeded once — candidates and companies both pick from this same list, enabling precise matching in BLOC 4.

Column Type Notes
id UUID PK
slug VARCHAR(50) UNIQUE machine-readable stable identifier — autonomy, work_life_balance
label VARCHAR(100) display label — Autonomie, Équilibre vie pro/perso

Why a controlled vocabulary? Free-text values make matching impossible — "bienveillance" and "bien-être" would not match despite being semantically close. A shared referential ensures candidate and company values speak the same language.


company_culture_values

Junction table — many-to-many between companies and culture_values.

Column Type Notes
company_id UUID FK companies.id
culture_value_id UUID FK culture_values.id
PRIMARY KEY (company_id, culture_value_id)

passport_culture_values

Junction table — many-to-many between passports and culture_values. Replaces the former professional_values TEXT[] column.

Column Type Notes
passport_id UUID FK passports.id
culture_value_id UUID FK culture_values.id
PRIMARY KEY (passport_id, culture_value_id)

job_offer_stages

Recruitment stages defined per job offer. Entity JobOfferStage (namespace App\Entity\Job) — renamed from JobOfferStage for namespace consistency. The product UI still calls this concept "Pipeline" (route /pipeline in recruiter/), but the data model uses the domain prefix JobOffer*.

Column Type Notes
id UUID PK
job_offer_id UUID FK
type VARCHAR(50) backed by JobOfferStageType enum (7 values): screening / interview / assessment / trial_day / reference_check / background_check / ats_step (system-only, not exposed in the manual selector)
custom_label VARCHAR(150) nullable displayed instead of type name when set
display_order SMALLINT
default_mode VARCHAR(10) nullable StageMode: onsite / video / phone. Offer-level default prefilled into the schedule form.
default_location VARCHAR(255) nullable default address (on-site stages)
default_meeting_link VARCHAR(2048) nullable default meeting link (remote stages)
default_duration SMALLINT nullable default duration in minutes
default_interlocutor VARCHAR(255) nullable default interlocutor (falls back to the connected recruiter at scheduling time)
default_instructions TEXT nullable default instructions shown to the candidate
default_format VARCHAR(10) nullable assessment only: live / take_home

applications

Column Type Notes
id UUID PK
candidate_id UUID FK
job_offer_id UUID FK
current_stage_id UUID FK nullable → pipeline_stages
status VARCHAR(20) see lifecycle below
peak_status VARCHAR(20) highest status ever reached — used for KPI calculations
created_at TIMESTAMPTZ
updated_at TIMESTAMPTZ bumped by any write
status_changed_at TIMESTAMPTZ bumped only by an actual status transition — the reliable pipeline-inactivity clock (recruiter dashboard, candidate follow-up), never touched by unrelated writes
is_pinned BOOLEAN NOT NULL DEFAULT false Recruiter-only pin signal. Independent of application status.
was_headhunted BOOLEAN NOT NULL DEFAULT false Permanent origin marker set once when the candidate was invited (headhunting), never reset.
last_followed_up_at TIMESTAMPTZ nullable Date of the candidate's last stale-application follow-up. Null until the first. Doubles as the 14-day cooldown clock.
last_follow_up_message TEXT nullable Optional message sent with the last follow-up (personal data, purged with the application).
deleted_at TIMESTAMPTZ nullable soft delete

UNIQUE constraint on (candidate_id, job_offer_id) — one application per candidate per offer.

Status lifecycle:

[Candidate]  sent ──────────────────────────────→ withdrawn
               ↓ (recruiter opens)
             viewed ──────────────────────────────→ rejected
               ↓ (recruiter moves to pipeline)
           in_process ───────────────────────────→ rejected
               ↓ (recruiter makes employment offer)
           offer_pending
               ↓                  ↓
            hired              declined  (candidate action)
Status Triggered by
invited Recruiter invites the candidate via headhunting (before any candidate action)
sent Candidate submits application
viewed Recruiter opens the application for the first time
in_process Recruiter moves to first pipeline stage
offer_pending Recruiter sends an employment offer
hired Candidate accepts the employment offer
declined Candidate declines the employment offer
rejected Recruiter rejects at any stage
withdrawn Candidate withdraws their own application
invitation_declined Candidate declines a headhunting invitation

application_events

Append-only journal of application lifecycle events: every status transition (including sent at creation and viewed on first recruiter view) and every stage advancement. Feeds the recruiter Suivi timeline and the candidate-side reason lookups (rejection, decline, withdrawal).

Column Type Notes
id UUID PK
application_id UUID FK
stage_id UUID FK nullable → pipeline_stages (null for status-only events)
status VARCHAR(20) snapshot of applications.status at this point
message TEXT nullable optional message (500 chars max) — decline reason or recruiter rejection message
rejection_reason VARCHAR(100) nullable rejection reason code. Null for non-REJECTED events.
withdrawal_reason VARCHAR(100) nullable withdrawal reason code (e.g. found_another_job). Null for non-WITHDRAWN events.
withdrawal_reason_detail TEXT nullable free-text detail (500 chars max), only set when withdrawal_reason = other.
created_at TIMESTAMPTZ immutable — no updated_at on event rows

application_stage_notifications

Per-candidate scheduling and notification instance for the candidate's current stage, produced by the recruiter "Save & notify" action. Mutable confirmation lifecycle, kept separate from the append-only application_events.

Column Type Notes
id UUID PK
application_id UUID FK
stage_id UUID FK → job_offer_stages
interaction VARCHAR(10) StageInteraction: confirm (synchronous appointment) / inform (asynchronous notice)
scheduled_at TIMESTAMPTZ nullable appointment date and time (confirm)
deadline DATE nullable submission deadline (inform, take-home assessment)
mode VARCHAR(10) nullable StageMode: onsite / video / phone (confirm)
location VARCHAR(255) nullable snapshot, editable per candidate
meeting_link VARCHAR(2048) nullable snapshot. For mode = video, system-generated internal join URL to the integrated interview room (no longer a recruiter-typed external link) — see design decision
duration SMALLINT nullable snapshot, minutes
interlocutor VARCHAR(255) nullable snapshot
note TEXT nullable snapshot, instructions / message to the candidate (500 chars max)
assignment_link VARCHAR(2048) nullable snapshot, take-home assignment link (inform)
estimated_effort VARCHAR(100) nullable snapshot, take-home estimated effort label (inform)
confirmation_status VARCHAR(15) nullable StageConfirmationStatus: pending / confirmed / not_available. Null for inform. Candidate-written (confirmed / not_available).
candidate_message TEXT nullable candidate message when not_available (500 chars max). Candidate-written.
requested_at TIMESTAMPTZ set/bumped on each notify, drives candidate pinning order
responded_at TIMESTAMPTZ nullable candidate response timestamp
cancelled_at TIMESTAMPTZ nullable set when the recruiter explicitly cancels the coordination, or when coordinating another stage supersedes a still-pending confirmation
cancellation_reason TEXT nullable optional recruiter-provided reason on explicit cancellation, shown to the candidate and included in the cancellation email (500 chars max). Reset to null on re-notify
ics_sequence SMALLINT iCalendar SEQUENCE of the last .ics emailed for this notification. Starts at 0, incremented on each subsequent confirm email so calendar apps auto-update an already-imported event. Exposed to the candidate as icsSequence
candidate_joined_at TIMESTAMPTZ nullable first participant_joined observed for the candidate in the integrated video room (mode = video). Derived from media-server lifecycle webhooks. Reset to null on reschedule
recruiter_joined_at TIMESTAMPTZ nullable first participant_joined observed for any company recruiter in the room. Reset to null on reschedule

One active notification per (application, current stage): reschedule upserts the row and resets confirmation_status to pending, and resets candidate_joined_at / recruiter_joined_at to null (a new appointment starts with no recorded attendance).

The recruiter-facing attendance status (pending / both attended / candidate no-show / recruiter no-show / nobody connected) is not stored: it is computed at read time from the two join timestamps and the join window (scheduled_at + (duration ?? 60) + 30 min grace), reusing the same window arithmetic as the interview-room access resolver.

RGPD: candidate_message is personal data. The FKs have no DB-level cascade (deletion is handled in the service layer, per the convention). When the application hard-purge is implemented, it must delete the related application_stage_notifications rows, otherwise the FK constraint blocks the purge and the personal data survives.


interview_feedbacks

Private post-interview feedback left by a party (candidate or recruiter) after an honored video interview. Visible only to WonderWork: never returned in any candidate- or recruiter-facing view. Acceptance is authorized by party membership and interview existence only, never gated on the webhook-derived attendance status.

Column Type Notes
id UUID PK
notification_id UUID FK → application_stage_notifications
author_role VARCHAR(10) candidate / recruiter (the side that authored it)
author_id UUID technical id of the authoring user/candidate. Not exposed; no display name stored
call_rating SMALLINT nullable step 1, call-experience rating. Null when that step was skipped
call_comment TEXT nullable step 1 optional comment (bounded length)
party_rating SMALLINT nullable step 2, rating of the other party. Null when that step was skipped
party_comment TEXT nullable step 2 optional comment (bounded length)
created_at TIMESTAMPTZ

Unique on (notification_id, author_role): one submission per party per interview (server-enforced, 409 on duplicate). The two "steps" are one wizard submitted in a single request; a skipped step leaves its *_rating / *_comment null.

RGPD: call_comment and party_comment are personal data (the evaluative party comment concerns the other person, who never sees it). The FK has no DB-level cascade; the application hard-purge must delete the related interview_feedbacks rows via the service layer.


candidate_discarded_offers

Records job offers a candidate has marked as "not interested". Used to exclude offers from the discovery feed and swipe stack.

Column Type Notes
id UUID PK
candidate_id UUID FK → candidates
job_offer_id UUID FK → job_offers
discarded_at TIMESTAMPTZ

UNIQUE constraint on (candidate_id, job_offer_id) — idempotent discard.

Why no "undo" record? Undo is handled client-side during the toast window. The API is only called after the window expires; the record is never created if the candidate undoes within the window.


candidate_bubbles

A candidate's saved job search. Stores a named filter set replayed over the full catalogue (GET /v1/job-offers) and browsed as a swipe deck. Private to its owner.

Column Type Notes
id UUID PK
candidate_id UUID FK → candidates
name VARCHAR(60) required, trimmed, not unique
filters JSONB saved catalogue filter set: work_arrangement, contract_type, work_time, city_geonames_id, salary_min, experience_level, availability, published_since, and the free-text query q. Drift-tolerant on read.
created_at TIMESTAMPTZ
updated_at TIMESTAMPTZ bumped on content edit (name/filters), never on a deck view
last_opened_at TIMESTAMPTZ moment the candidate last opened the deck; initialized at created_at, set to now() by POST /me/bubbles/{id}/seen. Basis of the "new offers" counter: offers matching the filters published after this instant, excluding discarded and applied. Distinct from the email-alert marker.
alert_enabled BOOLEAN daily email alert opt-in, false by default (AC1). Toggled by PUT /me/bubbles/{id}/alert and by the public unsubscribe endpoint.
last_notified_at TIMESTAMPTZ, nullable email-alert marker: cutoff for "new" offers in the next alert email. NULL while the alert has never been enabled; set to now() on opt-in and advanced after each send so offers are not re-listed (AC5). Distinct from last_opened_at (in-app badge).

Index on candidate_id. No UNIQUE constraint (duplicate names/filters allowed). Cap of 20 bubbles per candidate, enforced in the service layer. An empty filter set is valid (the deck then browses the full catalogue).

Why JSONB for filters? The filter set is opaque storage replayed verbatim against the catalogue query params; it is never queried by containment. Mirrors the Types::JSON pattern already used on passports.preferred_sectors.

Why no DB cascade? Deletion is handled in the service layer (convention). The account hard-purge must delete candidate_bubbles rows for the candidate.

Why no compatibility gauge stored? The deck reuses the catalogue scoring: offers with a match_scores row show their gauge, unscored offers show none. Bubbles never trigger mass on-demand scoring.


match_scores

Stored scores, always read from this table. Rows are produced by the async matching pipeline (perimeter-driven) or, since the on-demand US, by a synchronous candidate-triggered computation on an out-of-perimeter offer — the source column records which.

Column Type Notes
id UUID PK
candidate_id UUID FK
job_offer_id UUID FK
global_score FLOAT nullable null = not yet computed ("Score incoming" state)
relevance_score FLOAT nullable Occupational relevance (blocs 1, 2, 3, 6) — the number the candidate gauge and the feed threshold use
feasibility_score FLOAT nullable Practical feasibility (blocs 4, 5)
score_bloc_1 FLOAT nullable Job Title (20%)
score_bloc_2 FLOAT nullable Skills Matrix (32%)
score_bloc_3 FLOAT nullable Experience & Seniority (17%)
score_bloc_4 FLOAT nullable Cultural & Contextual Fit (5%)
score_bloc_5 FLOAT nullable Logistics (18%)
score_bloc_6 FLOAT nullable Languages (8%)
confidence_level VARCHAR(20) nullable very_high / high / medium / low
source VARCHAR(20) feed (perimeter pipeline) / on_demand (candidate-triggered)
computed_at TIMESTAMPTZ nullable
created_at TIMESTAMPTZ
updated_at TIMESTAMPTZ

UNIQUE constraint on (candidate_id, job_offer_id).

Why store per-bloc scores? GDPR Art. 22 requires the ability to explain automated decisions to candidates. Per-bloc scores provide the breakdown visible in the offer detail view.

Why a source column? An on_demand row means the matching perimeter missed an offer the candidate cared about — an analytics signal for future perimeter recalibration. Provenance is fixed at creation and never overwritten by recomputes.

Why no matched_critical / total_critical columns? BLOC 2 and BLOC 6 now use exact FK matching (skill_id). The frontend can recompute skill match counts deterministically by crossing passport_skills (from Zustand) with job_offer_skills (embedded in the match response). Storing counts that are always recomputable from source data would be redundant.

null vs 0: global_score = null means "not yet computed" (triggers "Score incoming" UI state). global_score = 0 means "computed and incompatible". Never conflate the two.


Pre-computation Architecture

TRIGGER: profile.saved OR job.published
         ↓
[Pre-filter — cheap checks, no algo]
  • work arrangement: candidate remote preference incompatible with offer remote_policy → score = 0, skip
  • salary floor: cand.min > offer.max → score = 0, skip
  • freshness: candidate last_login > 90 days ago (job.published trigger only) → skip entirely (no row created)
  Note: score = 0 means "computed, incompatible". score = null means "not yet computed" (Score incoming state).
  Pre-filter failures (work arrangement, salary) MUST produce score = 0, never null.
  Freshness failures produce no row — the API handles these states at read time.
         ↓
[Symfony Messenger — async queue]
  Enqueue MatchScoreJob(candidate_id, job_offer_id) for surviving pairs
         ↓
[Worker: compute 6 blocs]
  UPSERT match_scores SET global_score, bloc_1..6, confidence_level, computed_at
         ↓
[API read — always instantaneous]
  GET /v1/me/matches →
    SELECT * FROM match_scores WHERE candidate_id = X
    ORDER BY global_score DESC NULLS LAST
  No score filter — all offers returned (null scores appear at the bottom)

Scale considerations: - MVP (< 10K candidates, < 1K offers): pre-filter + Messenger sufficient - Growth (100K+ candidates): add pgvector approximate pre-filtering layer before the 6-bloc computation — architecture unchanged, only the pre-filter step is enriched


Key Indexes

-- Matching hot path
CREATE INDEX idx_match_scores_candidate ON match_scores(candidate_id, global_score DESC);
CREATE INDEX idx_match_scores_offer ON match_scores(job_offer_id, global_score DESC);

-- BLOC 1 — ROME matching
CREATE INDEX idx_passport_target_jobs_rome ON passport_target_jobs(rome_code);
CREATE INDEX idx_job_offers_rome ON job_offers(rome_code);

-- is_reconverting computation
CREATE INDEX idx_passport_professional_experiences_rome ON passport_professional_experiences(passport_id, rome_code)
  WHERE rome_code IS NOT NULL;

-- Skill matching (BLOC 2 + BLOC 6)
CREATE INDEX idx_passport_skills_passport ON passport_skills(passport_id);
CREATE INDEX idx_passport_skills_skill ON passport_skills(skill_id);
CREATE INDEX idx_job_offer_skills_offer ON job_offer_skills(job_offer_id);
CREATE INDEX idx_job_offer_skills_skill ON job_offer_skills(skill_id);

-- BLOC 6 — Language matching (filtered index on skill category)
CREATE INDEX idx_passport_skills_language ON passport_skills(passport_id)
  WHERE skill_id IN (SELECT id FROM skills WHERE category = 'language');
CREATE INDEX idx_job_offer_skills_language ON job_offer_skills(job_offer_id)
  WHERE skill_id IN (SELECT id FROM skills WHERE category = 'language');

-- BLOC 4 — Culture values matching
CREATE INDEX idx_company_culture_values ON company_culture_values(company_id);
CREATE INDEX idx_passport_culture_values ON passport_culture_values(passport_id);

-- City autocomplete (GeoNames)
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_cities_name_trgm ON cities USING GIN (name gin_trgm_ops);
CREATE INDEX idx_cities_country ON cities(country_code);

-- Soft delete filtering
CREATE INDEX idx_candidates_deleted ON candidates(deleted_at) WHERE deleted_at IS NULL;
CREATE INDEX idx_job_offers_status ON job_offers(status, deleted_at) WHERE deleted_at IS NULL;

Reference Data Tables

Static reference data used in dropdowns, validation, and matching. Seeded via Symfony fixtures. Served to frontend via standard API Platform GET collection endpoints. The frontend caches these with staleTime: Infinity (TanStack Query) — fetched once per session.

nationalities

Column Type Notes
id UUID PK
code VARCHAR(3) UNIQUE ISO 3166-1 alpha-2 (FR, US, DE…)
label VARCHAR(100) Display name — "France", "États-Unis"

~250 entries. Seeded from ISO 3166-1. Used in Q4 (nationality dropdown). Stored as FK on passports.nationality_id (replaces VARCHAR).

PHP Enums (not DB tables)

The following reference data is stored as VARCHAR on entities (no FK) and validated via PHP backed enums. Frontend uses matching TypeScript constants. No DB tables, no API endpoints.

  • Sector — ~26 values (tech, finance, healthcare…). Used in Q6 (main sector), Q11 (experience sector), Q15 (target sectors). PHP enum: src/Enum/Sector.php.
  • EducationLevel — 8 values (lycee, bac, bac_2… bac_8, other). Used in Q10 (academic degree form). PHP enum: src/Enum/EducationLevel.php.
  • ContractType — 6 values (cdi, cdd, interim, apprentissage, internship, freelance). Used in Q10 (experience form). PHP enum: src/Enum/ContractType.php.

Language flags: language skills live in the skills table (category = 'language') with a code column (ISO 639-1). The flag emoji mapping is a frontend-only constant (LANGUAGE_FLAGS) — it is not stored in the database.


Computed Signals

Signals derived from the data model that are surfaced to users. Two categories: persisted flags (stored in DB, pre-computed on events) and derived badges (computed at query time, never stored).


Persisted Flags

Stored in DB because they are used in SQL filters or are too expensive to compute per-request.

Flag Table Type Computation trigger Rule
has_experiences passports BOOLEAN nullable profile.saved event null = skipped, false = "I have none", true = has entries. UX nudging only — not used in matching.
has_qualifications passports BOOLEAN nullable profile.saved event null = skipped, false = "I have none", true = has entries. UX nudging only — not used in matching.
is_reconverting passports BOOLEAN profile.saved event ROME distance between the most recent passport_professional_experiences.rome_code (type: employment, is_current = true) and Q7 passport_target_jobs.rome_code. Distance ≥ 2 ROME domains → true. Stored because it feeds BLOC 1 weighting and recruiter filters.

Derived Badges

Computed at API query time by the StateProvider or service layer. Never stored in the database. Displayed in the recruiter app when browsing match results or candidate profiles.

Badge Display label Source data Derivation rule
Career Transition "Reconversion" passports.is_reconverting is_reconverting = true
Atypical Profile "Profil atypique" passport_professional_experiences.rome_code Count of distinct ROME domain letters (first character of rome_code) across all experience entries ≥ 3. Signals a multi-domain, versatile background.
High Potential "Haut potentiel" computed seniority + match_scores.global_score Computed seniority IN ('junior', 'mid') AND global_score >= 75. Junior or early-mid profile with a strong compatibility score.
Overqualified "Surqualifié" computed seniority + job_offers.experience_level + match_scores.score_bloc_3 Computed seniority exceeds job offer experience_level by ≥ 2 steps on the scale (junior → mid → senior → executive) AND score_bloc_3 >= 80.

Why not store badges? Their definition may evolve as product intuition grows. Storing them creates a migration risk every time the rule changes. Computing them at query time (from already-loaded data) adds < 1ms and keeps the rules in code, not in stale DB values.

Display rule: a candidate can carry multiple badges simultaneously. Badges are shown as chips on match cards and candidate profile headers.


Modeling Decisions Summary

Decision Choice Rationale
User entity CTI — abstract users + candidates + recruiters Single UserProvider + firewall, domain data stays clean and non-sparse
Auth vs domain separation Auth fields on users, domain fields on subclass tables Candidate and Recruiter have fundamentally different domain data
Token strategy Access JWT (15 min) + opaque refresh token (DB, rotated) Re-login every 15 min is unacceptable on mobile PWA; DB sufficient at this scale
Refresh token storage DB via gesdinet/jwt-refresh-token-bundle Avoids Redis dependency; can migrate later if performance requires it
Logout invalidation Delete refresh token from DB + clear cookies Access token expires naturally in 15 min — acceptable tradeoff for MVP
Theft detection Rotated token re-submitted → full revocation for that user Detects token theft; forces re-login as a security signal
Candidate vs Passport Separate entities (1:1) 50+ passport fields, distinct lifecycle
PassportQualification (Q18) STI 3 types (academic / self_taught / certification), display only, no algo query
PassportProfessionalExperience / PassportPersonalProject (Q10) Separate tables, all columns typed Two distinct entities with no shared fields except rome_code
Skill levels None (binary) Self-assessment unreliable, presence + TOP flag sufficient
TOP skills 3 max, ×1.20 flat boost Podium logic — signals top strengths, not diluted by 5
Salary (candidate) minimum_salary only Recruiter sets range, candidate sets floor — simpler and more honest
Salary matching offer.max >= cand.min Candidate asks "can this offer reach my floor?"
Score computation Pre-computed async Page load = 1 SQL read, never blocking computation
Score null vs 0 null = not computed, 0 = incompatible Prevents "Score incoming" from showing on genuinely incompatible pairs
Pre-filters work_arrangement + salary floor + freshness Eliminate obvious mismatches before 6-bloc computation
GDPR deletion soft delete everywhere 30-day retention then hard purge via async task
No cascade delete in DB Handled in service layer Full RGPD traceability on deletion
Application status 9 states (sent → hired/declined/rejected/withdrawn) Full ATS funnel covered including employment offer lifecycle
Application events Separate immutable table Audit trail + recruiter Suivi timeline — never update, only insert
ATS sync (TeamTailor) Deferred Tackled when coding the ATS integration US
has_experiences / has_qualifications BOOLEAN nullable on passports null = skipped, false = "I have none", true = has entries. UX nudging only — matching treats empty collections as score 0 regardless.
is_reconverting (ex reconversion_intent) BOOLEAN, stored in DB ROME distance computation is costly — pre-computed on passport save, used in BLOC 1 weighting
Seniority level Computed at scoring time, not stored Derived from total professional experience years (0–2 junior, 2–5 mid, 5–10 senior, 10+ executive). Removes self-declaration bias and one onboarding question. BLOC 3 Part 2 uses computed value.
experience_level on job_offers 4 levels (junior / mid / senior / executive) Aligned with computed seniority thresholds — BLOC 3 comparison requires same scale
Current job Derived from passport_professional_experiences where is_current = true No denormalized current_job_rome_code on passports. Matching algorithm extracts from experiences collection.
City references FK to cities table (GeoNames) everywhere No denormalized city labels. passports.city_of_residence_id, passport_preferred_cities.city_id, passport_professional_experiences.city_id all reference cities.geonames_id.
Reference data DB tables (nationalities, sectors, education_levels, contract_types) + API endpoints Single source of truth in DB, served via API, cached on frontend. Same pattern as culture_values. No shared JSON files.
Certifications in passport_qualifications STI type certification added — no extra columns for MVP credential_id / URL are not universal across certification types; defer to a future US if verification feature needed
Skill vocabulary skills catalog + rome_job_skills junction + rome_jobs / rome_job_appellations referentials Controlled vocabulary enables exact FK matching in BLOC 2 and BLOC 6 — no semantic NLP needed. Sourced from ROME 4.0 + WonderWork custom skills for tools and technologies. ROME jobs and appellations imported locally for autocomplete and skill suggestion.
PassportLanguage / JobOfferLanguage removed Merged into passport_skills / job_offer_skills with skill.category = 'language' Unified structure — BLOC 6 filters on category. CEFR levels handled via dedicated proficiency column.
PassportExpertise renamed PassportExpertise → PassportQualification "Expertise" was ambiguous (confused with skill expertise). "Qualification" is the standard HR term for academic background + certifications.
Skill levels No level on candidate side for non-language skills — proficiency (CEFR) only for languages Self-assessment unreliable and biased. Binary presence + TOP flag sufficient for BLOC 2. Recruiter can still specify level_required on job offer for display and future use.
matched_critical / total_critical removed from match_scores Derivable from exact FK matching by frontend With controlled vocabulary, skill match counts are always recomputable by crossing passport_skills ∩ job_offer_skills. Storing them would be redundant.
No score filter on GET /v1/me/matches All offers returned, sorted DESC NULLS LAST The 70% threshold is a UX concern, not an API filter. The frontend decides what to display. Null-score offers appear at the bottom.
Company culture values culture_values reference table + company_culture_values + passport_culture_values junction tables Controlled vocabulary required for matching — free-text TEXT[] makes BLOC 4 comparison unreliable
last_login_at on users TIMESTAMPTZ nullable Feeds candidate freshness filtering in pre-computation pipeline (skip candidates inactive > 90 days)
BLOC 6 (Credentials) removed — Certifications/education level was 5% of the score. Removed: adds UX complexity for recruiters (required to specify diploma/certs on every offer) for negligible algorithm value. Data remains on the passport as a display signal. 5% redistributed: +3% BLOC 2 Skills (38→41%), +2% BLOC 3 Experience (15→17%).
JobOffer.target_availability Dedicated enum JobOfferTargetAvailability (4 values), separate from candidate-side Availability (6 values) Two different semantics — "when the recruiter wants to fill" vs "when the candidate is ready". Mapped explicitly at scoring time, not via shared enum.
JobOfferSkill.importance nullable NULL when skill.category = 'language' Languages are scored in BLOC 6 by CEFR proficiency, not in BLOC 2 by importance. NULL is the honest representation of "not applicable" — avoids storing a fictitious default value in DB. Validated conditionally at write time.
PipelineStage renamed to JobOfferStage Entity in App\Entity\Job\ Aligns with namespace convention JobOffer* (JobOffer, JobOfferSkill, JobOfferStage). Product term "Pipeline" is preserved at the UI layer (route /pipeline) — only the data model uses the domain prefix.
JobOfferStageType enum refined to 7 values screening / interview / assessment / trial_day / reference_check / background_check / ats_step Generalist, non-tech-specific catalogue. assessment merges the former live_coding / take_home / case_study; phone_screen renamed screening (phone or video). The phantom values video_screening / technical_test (documented but never implemented) are dropped. ats_step stays system-only. custom_label covers fine-grained renaming. Migration remaps existing rows (phone_screen→screening, live_coding/take_home/case_study→assessment).
job_offer_stages default_* columns Nullable typed columns (mode, location, meeting_link, duration, interlocutor, instructions, format) Offer-level scheduling defaults, prefilled into the per-candidate schedule form. Nullable columns chosen over a 1:1 table (would only relocate the nulls and add a join) and over JSONB (loses typing and DB-level validation).
ApplicationStageNotification entity Per-candidate scheduling/notification instance Producer side, written by the recruiter "Save & notify" action. Data driven by nature (confirm / inform) plus mode (onsite / video / phone), not by stage type. Holds a snapshot of the effective values, editable per candidate. Mutable confirmation lifecycle, kept separate from the append-only application_events. Candidate-written columns (confirmation_status, candidate_message, responded_at) are filled by the candidate response endpoint (separate consumer US).
Integrated video interview room No Room entity — room identity = application_stage_notifications.id; per-participant access token minted on demand at join time (never persisted) Video interviews run on a self-hosted media server (RGPD: French infra, no US SaaS). meeting_link becomes the system-derived internal join URL for mode = video; the external-link input is dropped. Access is time-windowed (scheduled − 15 min → scheduled + duration + 30 min) and identity-scoped (company recruiter + application candidate). No new persisted PII, no purge cascade. Media provider behind an InterviewRoomProvider port.
JobOfferSkill.level_required consumed by BLOC 2 Mapped via candidate's is_top flag — no candidate-side level required Avoids re-introducing self-assessed levels on candidate skills (decision ligne 300). Coefficients: 1.0 (top × any required level), 1.0 (non-top × null/beginner/intermediate), 0.7 (non-top × advanced), 0.5 (non-top × expert), 0.0 (skill absent). Multiplied by importance weight (critical=3, important=2, nice_to_have=1). When level_required is null, no level pondération is applied for that skill — only presence is checked.