Skip to content

Database

Stack

  • PostgreSQL - relational database (separate DB per service)
  • Entity Framework Core 10 - ORM
  • PgWeb - browsing UI (available in dev via Aspire)

Databases

Database Service Description
questions Questions.Api Categories, questions, votes, learning lists
organizer Organizer.Api Job offers, interviews, dictionaries
portfolio Portfolio.Api CV portfolios (multiple variants per user, JSONB data)
users Users.Api User profiles (display name, bio, skills, URLs)

Questions Database Schema

erDiagram
    CATEGORIES ||--o{ CATEGORIES : "has children"
    CATEGORIES ||--o{ QUESTIONS : contains
    QUESTIONS ||--o{ QUESTION_VOTES : has
    QUESTIONS ||--o{ LEARNING_QUESTIONS : "added to"

    CATEGORIES {
        uuid id PK
        string name
        string description
        string icon_name
        int order
        uuid parent_category_id FK
    }

    QUESTIONS {
        uuid id PK
        uuid category_id FK
        string name
        string description
        int order
        int promote_count
        enum status "Approved|Pending|Rejected"
        uuid created_by
        timestamp created_on
        uuid modified_by
        timestamp modified_on
    }

    QUESTION_VOTES {
        uuid id PK
        uuid question_id FK
        uuid user_id
        timestamp voted_on
    }

    LEARNING_QUESTIONS {
        uuid id PK
        uuid question_id FK
        uuid user_id
        timestamp added_on
    }

Organizer Database Schema

erDiagram
    OFFERS ||--o{ INTERVIEWS : "has (cascade delete)"
    KEY_VALUE_LISTS

    OFFERS {
        uuid id PK
        string name
        string link
        string company
        string city
        enum status "New|Considered|Sent|Rejected"
        string[] reasons
        string description
        enum source "LinkedIn|NoFluffJobs|JustJoinIT|Pracuj|Email|Direct|Other"
        enum work_mode "Remote|Hybrid|Onsite"
        string intermediary
        decimal salary_min "precision(18,2)"
        decimal salary_max "precision(18,2)"
        enum salary_currency "PLN|EUR|USD"
        enum salary_type "Monthly|Hourly|Annual"
        int order "default 0"
        bool is_ai_generated "default false"
        string raw_content
        string source_url "max 500"
        uuid created_by
        timestamp created_on
        uuid modified_by
        timestamp modified_on
    }

    INTERVIEWS {
        uuid id PK
        uuid offer_id FK
        enum stage "Screening|Technical|HR|Offer|Onboarding"
        enum status "Scheduled|Completed|Failed|Cancelled"
        timestamp scheduled_at
        int duration "minutes"
        string notes
        string location
        uuid created_by
        timestamp created_on
        uuid modified_by
        timestamp modified_on
    }

    KEY_VALUE_LISTS {
        enum key PK "Cities|Positions|Reasons"
        string[] values
    }

Indexes

Table Columns Type
Offers Status, Order, CreatedOn Composite
Interviews OfferId, ScheduledAt, Stage Composite

Portfolio Database Schema

erDiagram
    PORTFOLIOS {
        uuid id PK
        string name "max 200"
        jsonb data "PortfolioData document"
        uuid created_by
        timestamp created_on
        uuid modified_by
        timestamp modified_on
    }

The data column stores a JSONB document with the following structure:

{
  "personalInfo": {
    "firstName": "", "lastName": "", "email": "",
    "phone": "", "location": "", "linkedIn": "", "website": ""
  },
  "summary": "",
  "experience": [
    { "id": "", "company": "", "position": "", "startDate": "",
      "endDate": "", "current": false, "description": "" }
  ],
  "projects": [
    { "id": "", "name": "", "description": "",
      "technologies": [], "link": "" }
  ],
  "skills": [{ "id": "", "name": "", "level": "" }],
  "education": [
    { "id": "", "institution": "", "degree": "",
      "field": "", "startDate": "", "endDate": "" }
  ],
  "certifications": [
    { "id": "", "name": "", "issuer": "", "date": "", "link": "" }
  ],
  "languages": [{ "id": "", "name": "", "level": "" }]
}

Indexes

Table Columns Type Notes
Portfolios CreatedBy Non-unique Multiple portfolios (variants) per user

Schema Evolution

  1. Initial migration — single portfolio per user (unique index on CreatedBy)
  2. AddPortfolioVariants — added Name column, changed to non-unique index (multiple named variants per user)

Users Database Schema

erDiagram
    USER_PROFILES {
        uuid id PK
        string display_name "max 100, required"
        string bio "max 5000"
        string preferred_role "max 100"
        int experience_years
        string location "max 100"
        string skills "max 5000"
        string linkedin_url "max 500"
        string github_url "max 500"
        string website_url "max 500"
        uuid created_by
        timestamp created_on
        uuid modified_by
        timestamp modified_on
    }

Indexes

Table Columns Type Notes
UserProfiles CreatedBy Unique One profile per user

Migrations

Setup (first time)

# Create tools manifest file
dotnet new tool-manifest

# Install tools
dotnet tool restore

Creating Migrations

# Questions API
dotnet ef migrations add <MigrationName> --project Questions.Api --context ApplicationDbContext --output-dir Infrastructure/Migrations

# Organizer API
dotnet ef migrations add <MigrationName> --project Organizer.Api --context ApplicationDbContext --output-dir Infrastructure/Migrations

# Portfolio API
dotnet ef migrations add <MigrationName> --project Portfolio.Api --context ApplicationDbContext --output-dir Infrastructure/Migrations

# Users API
dotnet ef migrations add <MigrationName> --project Users.Api --context ApplicationDbContext --output-dir Migrations

Applying Migrations

cd Questions.Api && dotnet ef database update
cd Organizer.Api && dotnet ef database update
cd Portfolio.Api && dotnet ef database update
cd Users.Api && dotnet ef database update

Remove Last Migration

dotnet ef migrations remove

Entity Classes

Questions.Api Entities

// Category with hierarchy
public class CategoryEntity : BaseEntity
{
    public required string Name { get; init; }
    public string? Description { get; init; }
    public string? IconName { get; init; }
    public int Order { get; init; }
    public Guid? ParentCategoryId { get; init; }
    public ICollection<CategoryEntity> ChildCategories { get; init; } = [];
    public ICollection<QuestionEntity> Questions { get; init; } = [];
}

// Question with voting and learning
public class QuestionEntity : AuditEntity
{
    public required string Name { get; set; }
    public string? Description { get; set; }
    public required Guid CategoryId { get; init; }
    public int Order { get; set; }
    public QuestionStatus Status { get; set; } = QuestionStatus.Approved;
    public int PromoteCount { get; set; }
    public ICollection<QuestionVoteEntity> Votes { get; init; } = [];
}

// Vote tracking (one per user per question)
public class QuestionVoteEntity : BaseEntity
{
    public required Guid QuestionId { get; init; }
    public required Guid UserId { get; init; }
    public DateTimeOffset VotedOn { get; init; }
}

// Learning list
public class LearningQuestionEntity : BaseEntity
{
    public required Guid QuestionId { get; init; }
    public required Guid UserId { get; init; }
    public DateTimeOffset AddedOn { get; init; }
}

Organizer.Api Entities

// Job offer
public class OfferEntity : AuditEntity
{
    public required string Name { get; set; }
    public string? Link { get; set; }
    public required string Company { get; set; }
    public required string City { get; set; }
    public required OfferStatus Status { get; set; } = OfferStatus.New;
    public IList<string> Reasons { get; set; } = [];
    public string? Description { get; set; }
    public OfferSource? Source { get; set; }
    public WorkMode? WorkMode { get; set; }
    public string? Intermediary { get; set; }
    public decimal? SalaryMin { get; set; }
    public decimal? SalaryMax { get; set; }
    public SalaryCurrency? SalaryCurrency { get; set; }
    public SalaryType? SalaryType { get; set; }
    public int Order { get; set; }
    public bool IsAiGenerated { get; set; }
    public string? RawContent { get; set; }
    public string? SourceUrl { get; set; }
    public ICollection<InterviewEntity> Interviews { get; init; } = [];
}

// Interview
public class InterviewEntity : AuditEntity
{
    public required Guid OfferId { get; init; }
    public OfferEntity Offer { get; init; } = null!;
    public required InterviewStage Stage { get; set; }
    public required InterviewStatus Status { get; set; } = InterviewStatus.Scheduled;
    public DateTimeOffset? ScheduledAt { get; set; }
    public int? Duration { get; set; }
    public string? Notes { get; set; }
    public string? Location { get; set; }
}

// Reference data
public class KeyValueListEntity
{
    public required DictionaryKey Key { get; init; }
    public IList<string> Values { get; init; } = [];
}

Portfolio.Api Entity

// CV portfolio (one or more variants per user)
public class PortfolioEntity : AuditEntity
{
    public required string Name { get; set; }
    public required PortfolioData Data { get; set; }
}

// Stored as JSONB — not a separate table
public class PortfolioData
{
    public PortfolioPersonalInfo PersonalInfo { get; set; } = new();
    public string Summary { get; set; } = "";
    public List<PortfolioExperience> Experience { get; set; } = [];
    public List<PortfolioProject> Projects { get; set; } = [];
    public List<PortfolioSkill> Skills { get; set; } = [];
    public List<PortfolioEducation> Education { get; set; } = [];
    public List<PortfolioCertification> Certifications { get; set; } = [];
    public List<PortfolioLanguage> Languages { get; set; } = [];
}

Users.Api Entity

// User profile (one per user)
public class UserProfileEntity : AuditEntity
{
    public required string DisplayName { get; set; }
    public string? Bio { get; set; }
    public string? PreferredRole { get; set; }
    public int? ExperienceYears { get; set; }
    public string? Location { get; set; }
    public string? Skills { get; set; }
    public string? LinkedInUrl { get; set; }
    public string? GitHubUrl { get; set; }
    public string? WebsiteUrl { get; set; }
}

Enums

Questions.Api

public enum QuestionStatus
{
    Approved = 0,
    Pending = 1,
    Rejected = 2
}

Organizer.Api

public enum OfferStatus { New, Considered, Sent, Rejected }
public enum OfferSource { LinkedIn, NoFluffJobs, JustJoinIT, Pracuj, Email, Direct, Other }
public enum WorkMode { Remote, Hybrid, Onsite }
public enum SalaryCurrency { PLN, EUR, USD }
public enum SalaryType { Monthly, Hourly, Annual }
public enum InterviewStage { Screening, Technical, HR, Offer, Onboarding }
public enum InterviewStatus { Scheduled, Completed, Failed, Cancelled }
public enum DictionaryKey { Cities, Positions, Reasons }

PostgreSQL Enum Mapping

Organizer.Api maps all 8 enums to PostgreSQL native enums:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.HasPostgresEnum<OfferStatus>();
    modelBuilder.HasPostgresEnum<OfferSource>();
    modelBuilder.HasPostgresEnum<WorkMode>();
    modelBuilder.HasPostgresEnum<SalaryCurrency>();
    modelBuilder.HasPostgresEnum<SalaryType>();
    modelBuilder.HasPostgresEnum<InterviewStage>();
    modelBuilder.HasPostgresEnum<InterviewStatus>();
    modelBuilder.HasPostgresEnum<DictionaryKey>();
}

Seeding Data

Organizer.Api seeds reference data:

modelBuilder.Entity<KeyValueListEntity>().HasData(
    new { Key = DictionaryKey.Positions, Values = new[] {
        "Software Developer", "Frontend Developer", "Backend Developer",
        "Fullstack Developer", "Junior Software Developer", "Junior Frontend Developer",
        "Junior Backend Developer", "Junior Fullstack Developer", "Mid Software Developer",
        "Mid Frontend Developer", "Mid Backend Developer", "Mid Fullstack Developer",
        "Senior Software Developer", "Senior Frontend Developer", "Senior Backend Developer",
        "Senior Fullstack Developer", "Lead Software Developer", "Lead Frontend Developer",
        "Lead Backend Developer", "Lead Fullstack Developer", "Software Architect",
        "Frontend Architect", "Backend Architect", "DevOps"
    } },
    new { Key = DictionaryKey.Cities, Values = new[] {
        "Warszawa", "Kraków", "Wrocław", "Łódź", "Poznań", "Gdańsk",
        "Gdynia", "Sopot", "Trójmiasto", "Szczecin", "Lublin", "Bydgoszcz",
        "Białystok", "Katowice", "Śląsk", "Remote", "Inne"
    } },
    new { Key = DictionaryKey.Reasons, Values = new[] {
        "Stos technologiczny", "Stawka", "Forma współpracy UoP",
        "Forma współpracy B2B", "Praca zdalna", "Praca hybrydowa",
        "Praca z biura", "Język obcy", "Język ojczysty",
        "Zakres obowiązków", "Lokalizacja", "Godziny pracy",
        "Opieka medyczna", "Multisport", "Wyjazdy służbowe",
        "Integracje", "Biuro", "Inne"
    } }
);

Query Best Practices

// Always AsNoTracking for reads
var questions = await db.Questions
    .AsNoTracking()
    .Where(q => q.CategoryId == categoryId)
    .ToListAsync(ct);

// Project to DTOs in Select
var response = await db.Questions
    .AsNoTracking()
    .Where(q => q.Status != QuestionStatus.Rejected)
    .OrderBy(q => q.Order)
    .Select(q => new QuestionResponse(
        q.Id,
        q.Name,
        q.Description,
        q.PromoteCount,
        q.Votes.Any(v => v.UserId == userId),
        db.LearningQuestions.Any(l => l.QuestionId == q.Id && l.UserId == userId),
        q.Status == QuestionStatus.Pending))
    .ToListAsync(ct);

// Consistent ordering
.OrderBy(q => q.Order)
.OrderBy(o => o.CreatedOn)

Avoid

// Missing AsNoTracking for read-only queries
var questions = await db.Questions.ToListAsync(ct);

// Over-fetching with Include
var questions = await db.Questions
    .Include(q => q.Category)
    .Include(q => q.Votes)
    .ToListAsync(ct);

// N+1 queries (fetching related data in loop)
foreach (var question in questions)
{
    question.Votes = await db.QuestionVotes
        .Where(v => v.QuestionId == question.Id)
        .ToListAsync(ct);
}

Audit

Audit fields are automatically set by AuditInterceptor:

  • CreatedBy - ID of the creating user
  • CreatedOn - creation timestamp
  • ModifiedBy - ID of the modifying user
  • ModifiedOn - modification timestamp

Never set these fields manually in handlers.