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.
- Catalogue every dev. Each developer has a profile with location, contact details, links, skills, experience and availability. Profiles are browsable and searchable.
- Show the work. Developers post projects and credit the other developers who worked on them.
- Link people. Follow anyone; two people who follow each other are connected. Connections can endorse each other's skills.
- Hire. Companies post jobs. A job accepts applications in the app, through an external link, or both.
| 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 |
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
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,CompanyUserand the company portal come frompu:saas:setup, run without its welcome flow, because that flow forces everyone to create a company.Skilllives in the main app because projects and jobs will use it too. It is a global taxonomy:SkillPolicyskips entity scoping, and only admins create skills.- The package is
developersand the model isDevelopers::Profile. That avoids a "profiles/Profile" package, and avoids clashing with Plutonium'spu:profileaccount-settings page.
- A person signs up as a
User. After login,/welcomechecks for a pending company invite, then sends them to/dashboard. - Anyone with neither a developer profile nor a company is sent to
/onboardingfirst, from any portal (RequiresOnboarding,User#onboarded?). - 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.
- 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. - The dashboard links to both, and offers to create whichever one they skipped. Recruiters invited to a company count as onboarded.
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::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_visibilitywill be enforced in the profile policies used by the public pages, so hidden contact fields never render.
Developers::Experience
profile,title,company_name, optionalcompany(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.
ProfileSkilljoins profiles and skills, withlevel(beginnertoexpert) andyears. Each skill appears once per profile. Endorsements will point at it.
Project
owner(a developer profile),title,slug,summary,body(markdown),repo_url,demo_url,started_on,ended_on,cover_image.
ProjectContributor
project,profile,role, andstatus(pendingorconfirmed).- 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
@handlefrom 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_imageisn't built yet; it waits on public image uploads.
Follow
-
follower_idandfollowee_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 aprofile_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
/@handlepages (plain controllers,Site::FollowsControllerandSite::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_countis 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.
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_urloraccepts_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 theApplyaction, which creates aJobApplication:job_post,profile,cover_note, optionalresumeattachment.status:submitted,reviewing,shortlisted,rejected,hired,withdrawn.- Unique on the job/developer pair.
- Company members manage applicants in
company_portal: a nestedjob_posts/:id/job_applicationsresource with status interactions. - Applicants see and can withdraw their applications in
dashboard_portal.
| 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.
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").
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.
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.
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.
Every searchable model includes Searchable and declares its fields:
class Developer < ApplicationRecord
include Searchable
searchable :name, :handle, :headline, :bio, :city, :country
endSearchable.search(q)usesLIKEfor 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.
- Kamal, using
config/deploy.ymlfromrails 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.
- Skeleton ✅: Plutonium + SQLite stack,
userandadminRodauth accounts, companies and invites, portals mounted, CI (tests plus standardrb), Kamal config. - Catalogue:
developerspackage with profile, experience and skills ✅, developer portal ✅, onboarding ✅, public directory (/devs) and/@handle✅. - Showcase: projects and the contributor confirm flow ✅. Public
/projectsand project pages, projects on profiles and the landing page. Still to do: cover images (waits on image uploads). - Hiring: companies and the company portal ✅,
hiringpackage 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. - Network: follows and connections, endorsements, and "people you may know" ✅. Contact details can be limited to connections ✅.
- Polish: landing page and admin moderation, then plug in the SQLite search.
| 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.