Admin rights are granted from the command line, never through a form (see Admin access). Some admin surfaces are documented with their subsystem: the moderation queue in moderation.md, the deliverability dashboard in email.md, ad review in ads.md, the OAuth-app kill switch in api.md and the legal-pages editor in ADMINS.md.
The admin panel lives at /admin, reached from the account menu (an "Admin"
entry that only admins see; there is no other link to it).
At the very top sits a live activity dashboard
(VutuvWeb.Admin.DashboardLive, embedded like the shell): four figure tiles —
confirmed sign-ups today, members online right now, posts and direct messages
today, each against yesterday, the last two with the time of the newest row.
Below the tiles one people card lists either the newest ten members or up to
ten of the members online, switched by a segment control or by tapping the
matching tile. New members come first: on a phone that is what an admin opens
the page for. A new member's row also shows where they live (city and country
of their first address, Vutuv.Dashboard.member_places/1, the country
translated from the stored English name) and their three most-endorsed tags
with the count of the rest. On a phone the segments say "New" and "Online".
The "online now" figure and its list update the instant a member connects or
disconnects (via VutuvWeb.Presence); the rest refreshes on a gentle timer, so
it stays current without a reload. The client IP check sits at the foot of the
page while it is healthy and moves to the top as a warning when the app only
sees the proxy hop.
Below it the panel groups the sections by what you come to do — Moderation & queues (moderation, member browser, deliverability), Communication (newsletters, audiences), Content & taxonomy (tags, usernames) and System & insights (API apps, daily report, ads) — each tile with a one-line description and, where it matters, a live count badge.
Every actionable admin page is a LiveView whose actions apply with no
reload: the member browser (/admin/users), the moderation queue + case page
(/admin/moderation, reload-free uphold/reject), the deliverability dashboard
(/admin/deliverability, thaw/clear) and the OAuth-app list (/admin/api_apps,
suspend/reactivate); the classic CSRF POST routes stay as the no-JS / scriptable
fallback.
A logged-in member without admin rights who opens /admin gets a 403 page that
explains exactly this: admin rights are granted by the instance operator
directly in the database (contact via Impressum).
A LiveView list of every account, linked from the /admin panel, that
feels like a native app — every change updates the page with no reload.
The default view answers "who just joined?": PIN-registered members
(email_confirmed?), newest first.
Search-as-you-type by name, @handle or email address (debounced; the
email is matched server-side to find the account for support/moderation but is
never shown in the listing), filter by registration (PIN-confirmed / not
confirmed / all) and account flag (admins / identity-verified / awaiting
verification / frozen / suspended / deactivated / unreachable), and click a
column header (Member, Username, Joined) to sort either way.
The whole filter/sort state lives in the URL (push_patch), so a view is
shareable and the back button restores it.
Each row shows the registration plus moderation/deliverability state as badges,
and an inline Verify button that flips the row to "Verified" in place and
emails the member (Vutuv.Accounts.verify_identity/1, shared with the legacy
POST /admin/users) — so the old identity-verification queue is now just the
?flag=unverified view of this page (the /admin panel links straight to it
and badges its size).
Verification is auto-revoked if the member later edits any identity detail
the badge vouches for (any name part — first/middle/last name, nickname or
honorific title — plus the birthday): the admin's ID check was made
against exactly those details, so User.changeset/2 clears identity_verified?
whenever one changes (the security chokepoint, so it bites on the edit form
and the /api/2.0 PATCH /me), and the profile editor shows a friendly
toast explaining the badge was removed and why (to keep verified profiles
trustworthy and stop fake verified accounts). Re-verification is a fresh admin
action.
Live result count, paging (the PageScroll JS hook returns you to the top of
the list on each page change) and clear-filters round it out.
Query, filter and sort live in Vutuv.Accounts.admin_user_filters/1 +
list_admin_users/3 (offset-paginated via Vutuv.Pages, 50/page).
Admins-only via the :admin live_session (on_mount :require_admin).
Each browser row's name links to a member detail LiveView
(VutuvWeb.Admin.UserDetailLive) instead of straight to the public profile, so
an admin investigating a member lands on one screen carrying:
- the member's account status — the same registration + moderation/
deliverability badges the browser row shows (shared with the browser through
VutuvWeb.Admin.MemberBadges, so the two never drift); - their jobs footprint (issue #934) — live and total postings, open
job-related moderation cases, and the cold-outreach counter against the
configured limit (amber once the member is at the cap), built by the same
Vutuv.Jobs.member_job_footprint/1the/admin/jobsposter drawer uses; - one-click links to the member's preference overrides
(
/admin/users/:id/preferences), their postings on the jobs board (/admin/jobs?q=<handle>) and their public profile.
Read-only and admins-only (same :admin live_session). A missing or malformed
:id redirects back to the browser rather than 500ing (the id is UUID-cast
before the DB lookup, like the sibling preferences page).
/admin/tags is the classic CRUD over the shared global tag catalog
(Vutuv.Tags.Tag: slug, name, description) — create, rename and delete the tags
members share. Deleting a tag removes it from every member who holds it
(cascading FK). It is also the one listing that shows a tag's alternative
names (issue #1338), marked and linked to the topic they belong to, so a tag
looked up under an absorbed name is still findable here.
/admin/tag_merges (VutuvWeb.Admin.TagMergeLive) folds several tags for one
topic into one page. It collects a set, not a pair: a topic is spread over
rails, rubyonrails, Ruby on Rails and ROR, and gathering those takes
several searches, because "rails" cannot turn up ROR and does turn up grails,
which is a different topic. So a search adds to a list, any entry can be taken
back out with one click, and only then is one of them picked to survive. Then the
screen says what would move before anything happens: how many profiles, posts,
follows and job postings change hands, and how many rows are dropped because
their owner already carries the surviving tag — the counts the sequence really
produces (Merge.preview_many/2), not the sum of the pairs. Each absorbed tag
becomes its own recorded merge, so the history below the form can take one back
without undoing the rest, and a tag the merge refuses is named with its reason
rather than quietly skipped. The same screen files a name
as an alternative before anyone types it (so ROR never becomes a page of its
own) and marks a pair as deliberately distinct, which refuses the merge for good.
The mechanics, the four refusals and why an alternative name is a tag row rather
than a separate table are in
social-graph.md.
A tag can be flagged an honor tag (tags.honor?, edited on the tag's page).
That reserves the tag name site-wide as an admin-granted badge — think
vutuv_developer for the core team. The rules:
- Members cannot self-assign it.
Vutuv.Tags.add_user_tag/2(the single self-assign chokepoint behind the tags editor, the JSON API, the LinkedIn import and account setup) refuses any name that resolves to an honor tag. - Members cannot self-remove it. Self-removal goes through
Vutuv.Tags.delete_user_tag/1, which refuses an honor user_tag ({:error, :honor}); the tags editor and the JSON API both inherit the guard. - Only admins grant/remove it. The tag's
/admin/tags/:slugpage grows a member roster (VutuvWeb.Admin.TagMemberController): add a member by@handleor email, list the holders, remove one. Assignment runs throughVutuv.Tags.admin_assign_tag/2/admin_unassign_tag/2, which bypass the reservation deliberately (the route is admin-gated). - It is not endorsable and renders with a small "honor tag" marker on the
profile chip and in the
.md/.txt/.json/.xmlagent-doc siblings (SectionDocs.tag_entry/1carrieshonor).
Reserve honor for fresh names. Flipping an already-widely-held tag locks its existing holders out of self-removal (there is no per-assignment "granted by admin" record); the edit form warns about this.
VutuvWeb.Admin.OrganizationLive (issue #930) is the oversight dashboard for the
verified organization pages (Vutuv.Organizations, see organizations.md). Three overview
tiles (live / pending / frozen), a search over name, alias and domain, status
filter chips (all / live / pending / frozen / archived) and a paginated list. All
filter/status/page/selection state lives in the URL (push_patch), so a view is
shareable.
Opening an organization reveals a detail drawer: its domains with verification
state and last_checked_at, the team roles, the names/aliases (a flagged alias —
one matching another verified organization — carries an amber ⚑; see the collision
guardrail in organizations.md), the rename history (former aliases) and the
claiming member. The actions act reload-free:
- Freeze / Unfreeze —
Organizations.admin_set_frozen/2sets/clearsfrozen_at, the exact effect of the report freeze (moderation.md): the page vanishes for the public but stays visible to its owner behind the owner banner. - Archive —
archive_organization/1sets statusarchived. - Delete —
delete_organization/1, only for a page with no job postings (deletable?/1; issue #932 adds the postings guard); otherwise archive.
A flagged-alias count surfaces both on this page and as the coral "attention"
badge on the /admin organization-pages card, and the operator's new-verified /
domain-dropped notices link here.
VutuvWeb.Admin.JobLive (issue #934) is the oversight dashboard for job postings
(Vutuv.Jobs, see jobs.md), sharing the OrganizationLive scaffold. Four
overview tiles (live / expiring within 7 days / frozen / open job-related
moderation cases), a search over title, poster (name / @handle) and
organization name, status filter chips (all / live / expiring / frozen /
closed / expired / draft) and a separate "⚑ Open reports" toggle chip that
narrows to postings with an open moderation case. Filter/status/report/page/
selection state lives in the URL (push_patch), so a view is shareable. The
counts come from Jobs.admin_overview_counts/0 (the open-case figure is
delegated to Moderation.open_case_count("job_posting"), so it can't drift), the
list from Jobs.admin_jobs_page/1.
Opening a posting reveals a detail drawer (Jobs.admin_job_detail/1): the
poster (linked), the employer attribution (a verified organization page vs a
free-text, unverified name), status, the view/apply counters, the published /
expiry dates, the poster footprint (live + total postings, open job-related
cases, and the cold-outreach counter — see below), and the report history
(every case for the posting, each linking to /admin/moderation/:id). The
actions act reload-free:
- Freeze / Unfreeze —
Jobs.admin_set_frozen/2sets/clearsfrozen_at, the exact effect of a report freeze (moderation.md): the posting vanishes from the public board and every machine channel (sitemap, JSON-LD, agent formats — thevisible_to?/indexable?/agent_visible?gates all key onfrozen_at) but stays visible to its poster and admins. The freeze pings the public board topic, so an open/jobsre-queries with no reload.jobs_freeze_compliance_test.exsis the standing guard that a freeze really strips every channel. - Close —
Jobs.admin_close/1ends a live posting with the:moderationclose reason (a regular ending, so the lifecycle stays honest), rather than deleting. - Delete —
Jobs.delete_job_posting/1, for clear abuse; purges the image files and settles any open case.
The cold-outreach counter is the anti-spam lever on pushy recruiting: Vutuv.Chat
caps how many new message requests one member may open to strangers (members who
don't already follow them) within a window (COLD_OUTREACH_LIMIT / _WINDOW_HOURS,
default 20 / 24h; see ADMINS.md and messages.md). A member over the cap gets a
friendly "try again later"; Chat.cold_outreach_count/1 reads the current spend
(without moving it, via RateLimiter.peek/2) so admins see it in the drawer. Report
rulings on a job posting reuse the standard strike ladder (moderation.md), which
ends in account deactivation — the "freeze the HR account" lever.
An open-case count surfaces both on this page and as the coral "attention" badge on
the /admin job-postings card.
A focused, admins-only classic controller page for putting any account into the moderation freezer without waiting for a report (issue #812).
Search by name, @handle or email address (the same matcher as the member
browser), then hit Freeze (or Unfreeze) on a row. Freezing goes through
the public, audited Vutuv.Moderation.admin_freeze_user/3 (not the private
set_user_moderation!/2): it sets frozen_at — which hides the profile and
everything it owns from everyone but the member and admins — and records a
caseless audit row in moderation_admin_actions. A freeze does not block
login (that is suspension/deactivation); a frozen member can still sign in and
sees their own amber banner. Freeze/unfreeze are CSRF-protected POSTs through
the :browser pipeline.
/admin/accounts/frozen is the paginated list of every currently-frozen account
(newest freeze first, <.pager>, 250/page) with a Source column (admin vs
report — an account frozen by an open report is labelled "report") and an
Unfreeze control. See moderation.md for the freeze machinery and the honest
403/410 HTTP status a withheld profile now returns (was a blanket 404).
Admins-only via the :admin pipeline (Plugs.RequireLogin + Plugs.AuthAdmin).
A focused, admins-only LiveView for permanently removing an account.
Search-as-you-type by name, @handle or email address (the same matcher as
the member browser), then hit Delete on a row to open an "Are you sure?"
confirmation modal naming the account.
Confirming runs the one Vutuv.Accounts.admin_delete_user/1 chokepoint, which
snapshots the account's details (name, @handle, id, every email address and
phone number, post count, join date) before the cascade, then deletes the
account and everything it owns (posts, phone numbers, email addresses, tags,
endorsements, images, follows) via delete_user/1.
The deleted member is never emailed; instead the operator
(sw@wintermeyer-consulting.de) gets a record email
(Emailer.account_deleted_notice/1) listing what was removed and the exact
deletion timestamp (UTC + Europe/Berlin).
Admins-only via the :admin live_session (on_mount :require_admin).
Admins compose a broadcast email at /admin/newsletters, save it as a
draft, send a test to any single address, then broadcast it to every
eligible member — and read the per-recipient delivery log (the protocol:
when which email went out, with status), which is searchable (recipient
email/@handle), filterable (kind: test/broadcast, status:
sent/suppressed/invalid/error), sortable by any column and paginated
(Vutuv.Pages + <.pager>, 50/page).
In the dev environment the newsletter pages link to the Swoosh mailbox at
/sent_emails (dev_mailbox?/0).
The body is trusted Markdown rendered to inline-styled HTML for clients
(Vutuv.Newsletters.Markdown, Earmark direct, no sanitizer so the styles
survive), with merge variables ({{greeting}} for a localized personal
salutation, plus
{{first_name}}/{{last_name}}/{{name}}/{{username}}/{{email}})
substituted per recipient — HTML-escaped in the HTML body, raw in the text body
and subject.
Every message goes through the one Emailer.newsletter_email/1 chokepoint (bulk
headers + RFC 8058 one-click unsubscribe).
It is opt-out (users.newsletter_emails?, default true, toggle on the
notification settings page or the unsubscribe link); the broadcast skips
unconfirmed, unreachable, suspended, deactivated and unsubscribed members, picks
each member's lowest-position deliverable address, and is single-send
(draft → sending → sent, an atomic lock stops a double click).
Sends run in the background (Vutuv.TaskSupervisor; inline in tests) and are
crash-proof per recipient: a malformed stored address (legacy imports left
addresses with spaces in them, which make the SMTP adapter's puny-encoding
raise) is trimmed, validated and logged as invalid instead of killing the
loop, and any per-recipient exception becomes an error row.
Each send is also time-bounded (deliver_bounded/1): it runs on a
supervised task the loop waits at most NEWSLETTER_SEND_TIMEOUT_SECONDS (60s
default) for, and a send that overruns is killed and logged error. This is
what keeps the auto-resume below honest — gen_smtp's :timeout option covers
only the socket connect, while each per-response read uses a hardcoded 20-minute
timeout, so without the bound a black-holing relay (socket open, no reply) could
freeze one send far past the 5-minute stuck window, so the broadcast looks dead
and gets a second run that double-mails the un-logged tail (#943). Bounding the
send keeps a delivery row landing well inside that window.
A broadcast whose task still dies mid-send (e.g. a blue/green deploy stopping
the slot) is auto-resumed: Vutuv.Newsletters.BroadcastResumer sweeps every
minute for newsletters stuck in sending with ≥5 minutes of no delivery
activity and finishes them, skipping recipients who already have a delivery row
(the staleness window plus a CAS lock on updated_at make the resume
double-send-safe across the deploy overlap).
Admins-only via the /admin pipeline.
Every vutuv.de link in the HTML body is click-tracked: its href (not
the visible URL) carries a signed per-recipient ?nlt= token
(VutuvWeb.NewsletterToken), so when a recipient follows it the
:browser-pipeline plug VutuvWeb.Plug.NewsletterClick records who clicked
which link when (newsletter_clicks) and redirects to the clean URL; the
plain-text version keeps the bare link, and external links are left alone.
The newsletter show page then carries a "Resonance" success overview
(recipients, members who clicked + rate, total clicks, and a clicks-per-link
table, all over the broadcast audience) plus a paginated click log at
/admin/newsletters/:id/clicks (who clicked which link, when).
The Datenschutzerklärung describes the tracking (legitimate interest, opt-out via the newsletter unsubscribe).
Instead of always sending to everyone, an admin builds a fixed audience and
watches the matching-member count update live (a LiveView in its own
:admin live_session, guarded by the :require_admin on_mount).
The builder has two modes, chosen by a toggle at the top: From filters (the
default, below) and Specific accounts — a hand-picked allowlist (e.g. a
small group of testers) where the filters are hidden and the admin searches
members by @handle and adds them one by one; the audience is then exactly
those accounts.
(This relies on the audience query treating an empty filter as matching nobody once any positive selector — a picked account or an added group — is present, so a hand-picked list resolves to just those members rather than everyone-plus-them; with no selector at all it still means everyone, which is what the "all members" and "send to the rest" groups need.)
Filters: language (locale), country (the free-text country on
profile addresses, chosen from existing values), age (min/max from
birthdate, translated to birthdate bounds), tag (members holding a tag,
looked up by name) and username (an ILIKE handle pattern where */? are
wildcards and a plain term is a contains-match).
An optional cap (max_size) takes N members for a test run — either the
oldest first (by join date) or a random sample of the pool
(random_sample) — and a group can add (included_group_ids, a union,
bypassing the filters) and/or subtract other groups (excluded_group_ids)
so "test run of 100, then the rest" partitions cleanly.
On top of the filters, individual accounts can be hand-picked: the paginated
preview/search list has a checkbox per member (tick to include, untick to
exclude), stored as included_user_ids/excluded_user_ids (exclusion wins); a
search-by-handle box finds any eligible member to add, Select all / Unselect
all apply to the whole current view (all matches, not just the page), and
excluded members show as undo-able "Removed" chips (capped, with a "+N more"
overflow so a bulk unselect can't render thousands).
The selection survives filter changes and paging.
The filter clauses combine as one Ecto dynamic so they can be OR-ed with the
added groups/accounts.
The name field comes pre-filled with a timestamped default (e.g. "Audience 2026-06-23 05:54", Berlin time), like the access-token form.
The builder shows a live, paginated preview of matching members (with links
to their profiles) so the admin can eyeball the filter, and each saved audience
has a show page (:show) listing its frozen members paginated, again as
profile links.
On save the matching members are frozen into a snapshot
(newsletter_group_members), so the subtraction is stable; the broadcast then
targets group ∩ still-eligible (a member who unsubscribed or bounced after the
snapshot is skipped).
The newsletter's broadcast card has an audience picker (all eligible
members, or a group) and records which group was used (newsletters.group_id).
A basic operator metric for one German calendar day (Europe/Berlin via
Vutuv.BerlinTime.day_bounds_utc/1) — confirmed-by-PIN new registrations
(email_confirmed? accounts created that day) plus the day's posts, reposts,
likes and bookmarks, and the day's email-deliverability events (hard
bounces, address deactivations, account freezes and thaws, from
Vutuv.Deliverability).
Admins read it at /admin/reports, a time machine: ?date=YYYY-MM-DD
(defaulting to yesterday) with prev/next links and a date picker.
Vutuv.Reports.DailyReporter, a supervised cron-style GenServer, schedules
itself for the next 00:05 Berlin and mails the previous day's report to the
operator through the Emailer chokepoint (daily_report_email/1), skipping
any day whose every metric is zero.
The email subject lists only the non-zero numbers (e.g. "vutuv Tagesbericht 20.06.2026: 5 Registrierungen, 12 Beiträge, 1 eingefrorenes Konto"), so the day's signal is readable at a glance.
Beyond the counts, every metric carries a capped detail list
(DailyReport.detail_limit/0 = 25 rows, oldest first) so the report names who
and what: the new members (name, @handle, profile link), the created posts
(first line + permalink), the reposters / likers / bookmarkers, the new
Fediverse followers, the bounced / deactivated / frozen / thawed addresses and
the removed spam accounts. VutuvWeb.ReportDetails.sections/1 normalizes the
sample into linked %{primary, secondary, path} lines that the email (HTML +
text) and the /admin/reports page all render, so the three surfaces stay in
sync; a busier day than the cap ends each section with a "… und N weitere" note.
Behind config :vutuv, :daily_report_email (off in tests, on by default).
One row per step the media pipelines run, so "the photo scan has been stuck for
an hour" is answerable from the admin area instead of from the server log. Each
pipeline still keeps its own status column on its own row; this table is the
log beside those, written by Vutuv.MediaJobs and read by nobody but this
page.
Three writers today, each opening a row around the slow part and closing it on every branch of the outcome:
| Kind | Written by | Opens around |
|---|---|---|
image_scan |
Vutuv.Moderation.ImageScans.judge_and_apply/3 |
the vision-model call |
video_conversion |
Vutuv.Videos.Job.run_steps/1 |
one whole attempt at a clip |
screenshot |
Vutuv.Posts.Screenshots.process/2 |
the Chromium capture |
A refusal is a finished job, not a failed one: the pipeline did its work and the answer was no. Only a step that could not be done at all (the scanner unreachable, an undecodable image, a failed H.264 encode, a capture error) is recorded as failed.
start/2 answers the %MediaJob{}, finish/2 and fail/2 take it and never
raise — every writer is a fire-and-forget task, so a log write must not be able
to take the work down with it, and both closers treat a nil job as a no-op. A
process killed between the two leaves the row running, which is not a leak
to repair: an unfinished step is exactly what this page exists to show.
The row carries subject_type + subject_id (the pipeline's own row, with no
foreign key — the subject lives in a different table per kind and the log has
to outlive it) plus the two real references, user_id and post_id. Both are
nullable: a screenshot of a remote post belongs to no member here, and a clip
has no post until it is published. That is why the Member sort uses a left
join — an inner one would silently drop the ownerless rows.
The page reuses VutuvWeb.BrowseTable (URL-driven search, sort and paging) and
VutuvWeb.UI.duration/1, the shared duration formatter added with it. There is
no stored duration column: MediaJobs.elapsed_ms/1 counts a finished job from
started_at to finished_at and a still-running one from started_at to now,
and the Took sort rebuilds that same expression in SQL. That is what makes
"longest first" put a job that has been running for hours at the top, where the
operator came to find it, instead of filing every unfinished row under an
unknown duration on the last page.
Rows are pruned after MEDIA_JOB_RETENTION_DAYS (90) by
Vutuv.MediaJobs.Sweeper, daily.