feat: LinkedIn profile search engine (Django + React + Elasticsearch + ELK)

Full-stack implementation of the Cyberyan full-stack trial task.

Backend (Django 5 / DRF)
- Normalised PostgreSQL model (Profile, Company, School, Skill, Experience,
  Education) as the source of truth, with GIN + trigram indexes.
- Self-healing CSV reader: the supplied export carries an injected watermark
  column, missing columns, rows split by broken quoting and a duplicated
  header. A signature-scored Needleman-Wunsch alignment reconstructs 284 clean
  records out of 336 physical rows; only 10 rows parse correctly without it.
- Pluggable search layer (Strategy): Elasticsearch by default, PostgreSQL
  full-text search as a fallback selected by a single environment variable.
- Keyword search with per-field boosts, fuzziness and phrase boosting, six
  faceted filters (OR within a filter, AND across filters), an experience
  range, four sort orders, highlighting and facet aggregations.
- JWT auth with rotation + blacklist, three roles, PII masking, scoped rate
  limits, a single error envelope, OpenAPI schema and health probes.

Frontend (React 18 / TypeScript / Vite)
- Debounced search with type-ahead, faceted filter panel, result cards,
  profile drawer, URL-synced search state, automatic token refresh.
- Highlight snippets rendered without dangerouslySetInnerHTML.

Infrastructure
- Hardened multi-stage images (non-root, read-only rootfs, healthchecks).
- docker-compose for the full stack plus dev and production overlays.
- ELK: structured JSON logs shipped asynchronously to Logstash, routed per
  event type, with a composable index template and a Kibana setup script.

Quality
- 61 backend tests (85% coverage), ruff/black/bandit/mypy, ESLint + tsc,
  GitHub Actions running lint, tests and both image builds.
- Persian documentation: README plus architecture, search, API, data,
  security and operations guides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnFo6fNaHCTAkJbfcdkqtG
This commit is contained in:
2026-09-03 08:13:10 +00:00
commit a464e550c9
133 changed files with 17019 additions and 0 deletions
@@ -0,0 +1,416 @@
# Generated by Django 5.1.15 on 2026-09-03 03:36
import django.contrib.postgres.fields
import django.contrib.postgres.indexes
import django.contrib.postgres.search
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Company",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("external_id", models.CharField(blank=True, db_index=True, max_length=128)),
("name", models.CharField(max_length=255, unique=True)),
("industry", models.CharField(blank=True, db_index=True, max_length=128)),
(
"size",
models.CharField(
blank=True, help_text="Head-count bucket, e.g. 51-200", max_length=32
),
),
("founded", models.PositiveIntegerField(blank=True, null=True)),
("website", models.CharField(blank=True, max_length=255)),
("linkedin_url", models.CharField(blank=True, max_length=255)),
("location_name", models.CharField(blank=True, max_length=255)),
("country", models.CharField(blank=True, db_index=True, max_length=128)),
],
options={
"verbose_name_plural": "companies",
"ordering": ("name",),
},
),
migrations.CreateModel(
name="School",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=255, unique=True)),
("type", models.CharField(blank=True, max_length=64)),
("website", models.CharField(blank=True, max_length=255)),
("linkedin_url", models.CharField(blank=True, max_length=255)),
("location_name", models.CharField(blank=True, max_length=255)),
("country", models.CharField(blank=True, max_length=128)),
],
options={
"ordering": ("name",),
},
),
migrations.CreateModel(
name="Skill",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=128)),
("slug", models.SlugField(max_length=140, unique=True)),
(
"profile_count",
models.PositiveIntegerField(
db_index=True,
default=0,
help_text="Denormalised counter used to rank filter suggestions.",
),
),
],
options={
"ordering": ("-profile_count", "name"),
},
),
migrations.CreateModel(
name="Profile",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"external_id",
models.CharField(
blank=True,
db_index=True,
help_text="LinkedIn numeric id from the source export.",
max_length=64,
),
),
("full_name", models.CharField(db_index=True, max_length=255)),
("first_name", models.CharField(blank=True, max_length=128)),
("last_name", models.CharField(blank=True, max_length=128)),
(
"gender",
models.CharField(
blank=True,
choices=[("male", "Male"), ("female", "Female"), ("", "Unknown")],
max_length=16,
),
),
("linkedin_url", models.CharField(blank=True, max_length=255)),
("linkedin_username", models.CharField(blank=True, max_length=128)),
("github_url", models.CharField(blank=True, max_length=255)),
("twitter_url", models.CharField(blank=True, max_length=255)),
("job_title", models.CharField(blank=True, db_index=True, max_length=255)),
(
"job_title_role",
models.CharField(
blank=True,
db_index=True,
help_text="Coarse job family, e.g. engineering.",
max_length=64,
),
),
("job_title_sub_role", models.CharField(blank=True, max_length=64)),
(
"seniority_levels",
django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=32),
blank=True,
default=list,
help_text="e.g. ['manager'], ['senior'], ['cxo'].",
size=None,
),
),
("industry", models.CharField(blank=True, db_index=True, max_length=128)),
("job_summary", models.TextField(blank=True)),
("job_start_date", models.CharField(blank=True, max_length=10)),
("location_name", models.CharField(blank=True, max_length=255)),
("location_locality", models.CharField(blank=True, max_length=128)),
("location_region", models.CharField(blank=True, db_index=True, max_length=128)),
("location_country", models.CharField(blank=True, db_index=True, max_length=128)),
("location_continent", models.CharField(blank=True, max_length=64)),
("summary", models.TextField(blank=True)),
(
"years_experience",
models.PositiveSmallIntegerField(blank=True, db_index=True, null=True),
),
("inferred_salary", models.CharField(blank=True, max_length=32)),
("connections", models.PositiveIntegerField(blank=True, null=True)),
(
"interests",
django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=128),
blank=True,
default=list,
size=None,
),
),
(
"languages",
django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=64),
blank=True,
default=list,
size=None,
),
),
("emails", models.JSONField(blank=True, default=list)),
(
"phone_numbers",
django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=32),
blank=True,
default=list,
size=None,
),
),
("skills_text", models.TextField(blank=True, editable=False)),
("companies_text", models.TextField(blank=True, editable=False)),
("education_text", models.TextField(blank=True, editable=False)),
(
"search_vector",
django.contrib.postgres.search.SearchVectorField(editable=False, null=True),
),
(
"current_company",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="current_employees",
to="profiles.company",
),
),
],
options={
"ordering": ("full_name", "id"),
},
),
migrations.CreateModel(
name="Education",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("school_name", models.CharField(blank=True, max_length=255)),
(
"degrees",
django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=128),
blank=True,
default=list,
size=None,
),
),
(
"majors",
django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=128),
blank=True,
default=list,
size=None,
),
),
(
"minors",
django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=128),
blank=True,
default=list,
size=None,
),
),
("gpa", models.DecimalField(blank=True, decimal_places=2, max_digits=4, null=True)),
("start_date", models.CharField(blank=True, max_length=10)),
("end_date", models.CharField(blank=True, max_length=10)),
(
"end_year",
models.PositiveSmallIntegerField(blank=True, db_index=True, null=True),
),
(
"profile",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="educations",
to="profiles.profile",
),
),
(
"school",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="educations",
to="profiles.school",
),
),
],
options={
"ordering": ("-end_year", "id"),
},
),
migrations.CreateModel(
name="ProfileSkill",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("position", models.PositiveSmallIntegerField(default=0)),
(
"profile",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="profile_skills",
to="profiles.profile",
),
),
(
"skill",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="profile_skills",
to="profiles.skill",
),
),
],
options={
"ordering": ("position",),
},
),
migrations.AddField(
model_name="profile",
name="skills",
field=models.ManyToManyField(
related_name="profiles", through="profiles.ProfileSkill", to="profiles.skill"
),
),
migrations.CreateModel(
name="Experience",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("company_name", models.CharField(blank=True, max_length=255)),
("title", models.CharField(blank=True, max_length=255)),
("title_role", models.CharField(blank=True, max_length=64)),
("title_sub_role", models.CharField(blank=True, max_length=64)),
(
"levels",
django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=32),
blank=True,
default=list,
size=None,
),
),
("start_date", models.CharField(blank=True, max_length=10)),
("end_date", models.CharField(blank=True, max_length=10)),
(
"start_year",
models.PositiveSmallIntegerField(blank=True, db_index=True, null=True),
),
("end_year", models.PositiveSmallIntegerField(blank=True, null=True)),
("is_primary", models.BooleanField(db_index=True, default=False)),
("location_name", models.CharField(blank=True, max_length=255)),
("summary", models.TextField(blank=True)),
(
"company",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="experiences",
to="profiles.company",
),
),
(
"profile",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="experiences",
to="profiles.profile",
),
),
],
options={
"ordering": ("-is_primary", "-start_year", "id"),
"indexes": [
models.Index(fields=["profile", "is_primary"], name="exp_profile_primary")
],
},
),
migrations.AddConstraint(
model_name="profileskill",
constraint=models.UniqueConstraint(
fields=("profile", "skill"), name="uniq_profile_skill"
),
),
migrations.AddIndex(
model_name="profile",
index=django.contrib.postgres.indexes.GinIndex(
fields=["search_vector"], name="profile_search_vector_gin"
),
),
migrations.AddIndex(
model_name="profile",
index=django.contrib.postgres.indexes.GinIndex(
fields=["seniority_levels"], name="profile_seniority_gin"
),
),
migrations.AddIndex(
model_name="profile",
index=models.Index(
fields=["location_country", "job_title_role"], name="profile_country_role"
),
),
migrations.AddConstraint(
model_name="profile",
constraint=models.UniqueConstraint(
condition=models.Q(("external_id__gt", "")),
fields=("external_id",),
name="uniq_profile_external_id",
),
),
]
@@ -0,0 +1,33 @@
"""
Enable the PostgreSQL extensions the search fallback relies on.
``pg_trgm`` powers ``TrigramSimilarity`` (typo tolerant name matching) and the
GIN trigram index below makes ``ILIKE '%term%'`` on skill names fast enough for
the type-ahead endpoint.
"""
from django.contrib.postgres.operations import TrigramExtension, UnaccentExtension
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("profiles", "0001_initial")]
operations = [
TrigramExtension(),
UnaccentExtension(),
migrations.RunSQL(
sql=(
"CREATE INDEX IF NOT EXISTS profile_full_name_trgm "
"ON profiles_profile USING gin (full_name gin_trgm_ops);"
),
reverse_sql="DROP INDEX IF EXISTS profile_full_name_trgm;",
),
migrations.RunSQL(
sql=(
"CREATE INDEX IF NOT EXISTS skill_name_trgm "
"ON profiles_skill USING gin (name gin_trgm_ops);"
),
reverse_sql="DROP INDEX IF EXISTS skill_name_trgm;",
),
]