Skip to content

Latest commit

 

History

History
347 lines (284 loc) · 18.2 KB

DESIGN.md

File metadata and controls

347 lines (284 loc) · 18.2 KB

DevCongress Connect — Design

A catalogue of our developers, what they've built, who they know, and where they can work next. Built on Plutonium, SQLite, and deployed self-hosted with Kamal.

This replaces the DevCongress Jobs app. The old app never shipped, so there is no data to migrate; we carry over its ideas, not its tables.

Goals

  1. Catalogue every dev. Each developer has a profile with location, contact details, links, skills, experience and availability. Profiles are browsable and searchable.
  2. Show the work. Developers post projects and credit the other developers who worked on them.
  3. Link people. Follow anyone; two people who follow each other are connected. Connections can endorse each other's skills.
  4. Hire. Companies post jobs. A job accepts applications in the app, through an external link, or both.

Stack

Concern Choice
Framework Rails 8.1 + Plutonium (resources, policies, interactions, portals)
Database SQLite for everything (primary, queue, cache, cable, errors)
Background jobs Solid Queue, with recurring tasks in config/recurring.yml
Backups Litestream
Auth Rodauth: a user account (email and password, plus Google and GitHub sign-in via rodauth-omniauth when their credentials are set) and an admin account (invite-only, TOTP available)
UI Phlex, Tailwind, Stimulus, Turbo (Plutonium defaults), styled after devcongress.org: cream/ink/pink/yellow, DM Serif Display + Inter (self-hosted), pill buttons, ink-bordered cards with a pink offset shadow (.dc-card)
Search Behind a Searchable concern. LIKE for now; swapped for the in-house SQLite search when it lands
Deploy Kamal on our own hosts, with SQLite files on a mounted volume
Config Environment variables only; no Rails credentials

Layout

Public pages live in the main app. Signed-in work happens in portals. Domain code is grouped into feature packages.

app/                    main app: User, Admin, Company, CompanyUser, Skill
                        public pages: landing, /devs, /@handle, /projects, /jobs, /companies
packages/
  developers/           Developers::Profile, Developers::Experience, Developers::ProfileSkill
  showcase/             Project, ProjectContributor, ProjectSkill
  network/              Follow, Endorsement
  hiring/               Hiring::JobPost, Hiring::JobApplication
  invites/              CompanyUserInvite (generated by pu:saas:setup)
  dashboard_portal/     /dashboard           (user account)  home: links to your profile and companies
  developer_portal/     /developer/:handle   (user account, scoped to Developers::Profile) profile, experience, skills
  company_portal/       /company/:slug       (user account, scoped to Company) jobs, members, invites, applicants
  admin_portal/         /admin               (admin account) moderation, skills taxonomy

Two kinds of entity

Developer profiles and companies are both entities. Each has its own portal, scoped by URL, and users reach only the ones they own or belong to.

Entity Portal Who can open it How a user gets one
Developers::Profile /developer/:handle Its owner. One profile per user (belongs_to :user) Onboarding, or /setup/developer/new later
Company /company/:slug Its members (CompanyUser, role owner or recruiter) Onboarding, /setup/company/new, or an email invite
  • User, Company, CompanyUser and the company portal come from pu:saas:setup, run without its welcome flow, because that flow forces everyone to create a company.
  • Skill lives in the main app because projects and jobs will use it too. It is a global taxonomy: SkillPolicy skips entity scoping, and only admins create skills.
  • The package is developers and the model is Developers::Profile. That avoids a "profiles/Profile" package, and avoids clashing with Plutonium's pu:profile account-settings page.

Accounts and onboarding

  1. A person signs up as a User. After login, /welcome checks for a pending company invite, then sends them to /dashboard.
  2. Anyone with neither a developer profile nor a company is sent to /onboarding first, from any portal (RequiresOnboarding, User#onboarded?).
  3. Onboarding asks what they're here for: "I'm a developer" (name, handle, headline, location) and/or "I'm hiring for a company" (company name, website). At least one is required.
  4. Submitting creates whichever was chosen, in one transaction (app/forms/onboarding.rb), and lands them in that portal. If they chose both, they land on the dashboard.
  5. The dashboard links to both, and offers to create whichever one they skipped. Recruiters invited to a company count as onboarded.

Domain model

users ─1:1─ developers_profiles ─< developers_experiences
                │   ├─< developers_profile_skills >─ skills ─< project_skills >─ projects
                │   ├─< project_contributors >───────────────────────────────────┘
                │   ├─< follows (follower_id → followee_id)
                │   ├─< endorsements (endorser_id, profile_skill_id)
                │   └─< job_applications >─ job_posts
users ─< company_users (role) >─ companies ─< job_posts ─< job_renewals

developers package

Developers::Profile (the catalogue entry, at most one per user)

Group Fields
Identity handle (unique, used in /@handle and /developer/:handle), name, headline, bio (markdown), avatar (later)
Location city, region, country, timezone, remote_ok, open_to_relocation
Contact contact_email, phone, website_url, github_url, linkedin_url, x_url
Status availability (not_looking, open, looking), seniority, years_experience
Privacy contact_visibility (everyone, members, connections), listed (appears in the directory)
  • contact_visibility will be enforced in the profile policies used by the public pages, so hidden contact fields never render.

Developers::Experience

  • profile, title, company_name, optional company (links to a company on the site), location, started_on, ended_on (blank for the current role), description.

Skill (main app) and Developers::ProfileSkill

  • A curated taxonomy that admins manage. ProfileSkill joins profiles and skills, with level (beginner to expert) and years. Each skill appears once per profile. Endorsements will point at it.

showcase package

Project

  • owner (a developer profile), title, slug, summary, body (markdown), repo_url, demo_url, started_on, ended_on, cover_image.

ProjectContributor

  • project, profile, role, and status (pending or confirmed).
  • The owner invites a contributor and the contributor confirms. Only confirmed contributors show publicly, so nobody can claim work with someone who didn't agree to it.

ProjectSkill

  • The stack a project was built with.

As built

  • Owners manage projects in the developer portal (Projects) and add contributors by @handle from the project's Contributors tab. Hidden profiles can't be added, and nobody can add themselves.
  • The contributor gets an email and confirms or declines from Credits. Only confirmed contributors show on the project page and get the project on their profile; the owner gets an email when they confirm.
  • Contributors whose profiles the viewer can't see are left off public pages.
  • The slug is set from the title when the project is created and never changes, so shared links keep working.
  • cover_image isn't built yet; it waits on public image uploads.

network package

Follow

  • follower_id and followee_id, both developers, unique on the pair.

  • Connection = mutual follow. It is not a table. It is a scope:

    # Developer
    has_many :following, through: :outgoing_follows, source: :followee
    has_many :followers, through: :incoming_follows, source: :follower
    def connections = following.where(id: followers.select(:id))  # both directions exist
    def connected_to?(other) = connections.exists?(other.id)

    Two indexed lookups, and there is no request/accept state to keep in sync. "Accepting" a connection is just following someone back.

  • The UI shows Follow, Following, Follow back or Connected, depending on the two follow rows.

Endorsement

  • endorser (a developer profile) and a profile_skill.
  • The policy only allows it when the endorser is connected to the skill's owner, and nobody can endorse themselves.

As built

  • Follow and endorse buttons are on /@handle pages (plain controllers, Site::FollowsController and Site::EndorsementsController), and need a developer profile. Hidden profiles can't be followed and never appear in network lists.
  • The validation lives on the model, so a connection is required however the endorsement is made. profile_skills.endorsements_count is a counter cache; skills on profiles are sorted by it.
  • Endorsements stay when people unfollow, like a reference that's been given.
  • The developer portal's Network page has Connections, Followers, Following and People you may know: people your connections follow, ranked by how many of them do, then people who share your skills.
  • Following emails the person followed (or tells them they're connected), at most once a week per pair so unfollowing and refollowing can't spam anyone.
  • Profile routes in the developer portal declare their nested associations, so followers, following and endorsers don't get portal routes.

hiring package

Ported from the old app, with its rough edges removed.

Old app New app
Company with clients linking users Company (slug, name, website, description, city, country) and CompanyUser (role: owner / recruiter), both in the main app. Members join through email invites
Ownership checks repeated in controllers company_portal is scoped to Company through the URL path, so policies only ever see the current company's rows
Job with salary as a numrange JobPost: title, description (markdown), employment_type, seniority, salary_min, salary_max, salary_currency, remote_ok, city, country, apply_url, accepts_applications, published_at, expires_at, filled_at, archived_at
renewals table plus the 30-day window in raw SQL JobRenewal keeps the history. expires_at is stored on the job, so JobPost.active is where(archived_at: nil, filled_at: nil).where("expires_at > ?", Time.current)
Actions to mark filled/vacant and renew Interactions: PublishJob, RenewJob (only once expired), MarkFilled, MarkVacant, ArchiveJob
scheduler.rake expiry emails JobExpiryReminderJob and JobExpiredJob in config/recurring.yml, sending the same four JobsMailer emails
Tweet on publish Dropped. Could come back later as an AnnounceJob webhook

Ways to apply

  • A job must have apply_url or accepts_applications, and can have both. This is a model validation.
  • With apply_url, the job page shows a button that links out.
  • With accepts_applications, a signed-in developer runs the Apply action, which creates a JobApplication:
    • job_post, profile, cover_note, optional resume attachment.
    • status: submitted, reviewing, shortlisted, rejected, hired, withdrawn.
    • Unique on the job/developer pair.
  • Company members manage applicants in company_portal: a nested job_posts/:id/job_applications resource with status interactions.
  • Applicants see and can withdraw their applications in dashboard_portal.

Portals and access

Surface Who Contents
Main app (public) Anyone Landing page, developer directory with filters (skill, country, availability, remote), /@handle page, projects, jobs, company pages. Plain Rails controllers and Phlex views, reading through the same policies
dashboard_portal Signed-in user Home: links to my developer profile and companies, and creates whichever is missing
developer_portal The profile's owner Profile, experience, skills (with endorsement counts), projects and their contributors, credits to confirm, network, jobs and applications
company_portal Company members Job posts and their lifecycle actions, applicants, members
admin_portal Admin account Every resource, skills taxonomy, hiding/unlisting developers, projects or jobs; reviewing follows, endorsements and project credits
  • Signup and onboarding: see Accounts and onboarding above.

Posts: jobs, gigs and internships

A Hiring::JobPost is any kind of post. employment_type decides the kind:

Kind Types Extra fields
Jobs full time, part time, contract
Gigs freelance duration, start date
Internships internship duration, start date, paid or unpaid

Contracts also get duration and start date. Pay is a range with a basis (pay_period: per year, month, day, hour, or a fixed budget), so the board can show "500 USD fixed budget" or "40 USD per hour". The public board has All / Jobs / Gigs / Internships tabs (/jobs?kind=gigs), and wording follows the kind ("Your gig is live").

Individual posters

Anyone with a developer profile can post as themselves. Behind the scenes this is a personal posting space: a Company row with personal_owner_id set, owned by that one user. It reuses everything companies have (company portal, applicant board, first-post review, emails), while the public site shows the person instead: their name and round avatar on posts, "About the poster", and /companies/<slug> redirects to their /@handle page, which lists their open posts. Personal spaces have no team: no members, invites or company settings. Nobody can apply to their own post.

Applicant tracking

Companies manage applicants in company_portal (/company/:slug/hiring/job_applications):

  • Board (default view): one column per stage: New, Reviewing, Shortlisted, Hired, Rejected, Withdrawn. Dragging a card moves the applicant; dropping on Rejected asks for an optional message first. Withdrawn is locked. Cards are ordered within a column (position, scoped to status).
  • Table: search by applicant, filters (job, stage, minimum rating, applied dates), "Open" and "Needs review" tabs, bulk "Move to stage", and CSV export with contact details.
  • Application page: applicant summary and contact details (applying shares them with the company), CV, cover note, skills and experience, team rating, and an activity log of private notes and stage changes (who and when).
  • Emails: the applicant hears about every move forward, hire or rejection, with the team's optional message. Moving back to New is silent.
  • Notes and ratings are never shown to the applicant; their own view shows the stage and their CV.

Uploads

active_shrine (Shrine with an Active Storage-style API) stores files privately under storage/uploads, which is not web-served. Pages hand out short-lived signed links (PrivateFilesHelper, one hour) that PrivateFilesController checks before streaming the file. Only render a link where the viewer is already allowed to see the file. CVs (ResumeUploader) must be PDF or Word, 5 MB at most.

Search

Every searchable model includes Searchable and declares its fields:

class Developer < ApplicationRecord
  include Searchable
  searchable :name, :handle, :headline, :bio, :city, :country
end
  • Searchable.search(q) uses LIKE for now.
  • When the in-house SQLite search is ready, only the concern changes.
  • Plutonium definitions call search { |scope, q| scope.search(q) }, and the public pages call the same method.

Deploy

  • Kamal, using config/deploy.yml from rails new.
  • One web role, with Solid Queue in-process (SOLID_QUEUE_IN_PUMA).
  • storage/ is on a persistent volume, and Litestream replicates the SQLite files to S3-compatible storage.

Build order

  1. Skeleton ✅: Plutonium + SQLite stack, user and admin Rodauth accounts, companies and invites, portals mounted, CI (tests plus standardrb), Kamal config.
  2. Catalogue: developers package with profile, experience and skills ✅, developer portal ✅, onboarding ✅, public directory (/devs) and /@handle ✅.
  3. Showcase: projects and the contributor confirm flow ✅. Public /projects and project pages, projects on profiles and the landing page. Still to do: cover images (waits on image uploads).
  4. Hiring: companies and the company portal ✅, hiring package with job posts, lifecycle actions (publish, renew, filled, reopen, archive) and applications ✅, developers browse and apply from the developer portal ✅. Public /jobs, job and company pages ✅. A new company's first job is reviewed by an admin (emailed) before it goes live; approval trusts the company ✅. Applicant tracking: board, filters, notes, ratings, bulk moves, CSV export, private CV uploads ✅. Gigs and internships, pay basis, and individual posters ✅. Still to do: expiry reminder emails, active-job cap per company.
  5. Network: follows and connections, endorsements, and "people you may know" ✅. Contact details can be limited to connections ✅.
  6. Polish: landing page and admin moderation, then plug in the SQLite search.

Visibility

Record Settings Default
Developer profile Public (directory, /@handle, search engines) · Members only · Hidden (owner only) Members only
Contact email/phone Everyone who can see the profile · Members only · Connections only Members only
Project Everyone who can see the owner's profile · Members only · Hidden (owner only). Never more visible than the owner's profile Everyone
Job post Public · Members only. Drafts, jobs in review, expired, filled and archived jobs are never shown Public

Guests see public records; signed-in members also see members-only ones. Pages for non-public records are marked noindex.