Skip to content

Unify member profile onboarding, Slack access, and course registration #108

Description

@alexeygrigorev

Outcome

Create one account-owned, private member profile and one resumable journey:

  1. minimal account ownership and verified email;
  2. required member-profile completion;
  3. immediate Slack-access eligibility and durable link delivery;
  4. course/cohort-specific registration questions only.

The same member values are entered once, remain editable in account settings, prefill future registrations, and are copied into immutable registration snapshots only where historical cohort reporting requires them.

This is not the GitHub-backed public editorial Person model.

Product and architecture authority

Current adopted facts to preserve:

  • accounts.CustomUser is the single durable account and already stores certificate name, country/region, registration role, GitHub/LinkedIn/website URLs, About me, timezone, preferences, login/social relations, and course relations.
  • CourseRegistration already snapshots email, name, optional company, country/region, role, comment, and newsletter state.
  • The current registration form may prefill account country/role and writes non-empty registration values back to the account.
  • The adopted Datamailer work is migration input; the target delivery contract is the email_app EmailDelivery outbox.
  • Public Person names, bio, portrait, and editorial relationships remain GitHub-owned.

Hard domain boundary

MemberProfile:

  • is database-owned by accounts and belongs one-to-one to the authenticated CustomUser;
  • represents private community membership and learner onboarding;
  • has no public profile route in this issue;
  • may be read or changed only by its owner or an explicitly authorized support capability;
  • never creates, updates, publishes, or automatically links a content Person.

Person:

  • remains GitHub-backed and public;
  • represents authors, speakers, guests, hosts, instructors, maintainers, and other editorial relationships;
  • grants no account, course, Studio, or staff authority.

A later reviewed relation may link an account and a Person. Either side may exist without the other, the records are not field-synchronized, and the link grants no permission or public role. No name, employer, seniority, profile link, Slack state, course activity, or email match may infer the link.

MVP member-profile contract

MemberProfile is the canonical store after migration. Existing CustomUser columns remain temporary compatibility projections only while adopted consumers move through an expand-and-contract migration; all new HTML, learner API, Studio, and registration writes use one accounts application service.

Field MVP rule
Country Required, canonical ISO 3166-1 alpha-2 code selected/confirmed by the member. Region is derived, not entered.
Work status Required code-defined choice: employed, self_employed, student, between_roles, not_working, prefer_not_to_say.
Organization/workplace Optional plain text, 160 characters. Blank is valid for every status; nobody invents an employer.
Professional role/focus Required code-defined choice preserving all adopted CourseRegistration.Role values: data_engineer, data_scientist, data_analyst, ml_engineer, software_engineer_backend, software_engineer_other, student_stem, student_non_stem, other; add prefer_not_to_say.
Seniority Required code-defined choice: learning, entry, mid, senior, lead_or_manager, executive_or_founder, not_applicable, prefer_not_to_say.
About/bio Required plain text, 1–1,000 characters after trimming.
Ambitions/goals Required plain text, 1–1,000 characters after trimming.
Why joined Required plain text, 1–1,000 characters after trimming.
GitHub, LinkedIn, website Optional, at most one URL each, maximum 500 characters. Accept only http/https, reject userinfo/control characters/unsafe schemes, never fetch the URL synchronously, and render external links with safe rel attributes.
Certificate name and preferred timezone Existing optional account settings, preserved and reusable but not part of Slack completion.

Choices are code-defined and migration-stable in MVP, not editable configuration in Studio. Labels can be translated later without changing stored values. Free text is stored as text, escaped on rendering, excluded from ordinary logs/audits/metrics, and never interpreted as Markdown/HTML.

Completion schema version 1 requires a verified account email plus every required field above. Store completion version, completed_at, and the revision of the member-confirmed profile. Suggestions or migrated values do not count as confirmed until the member submits them. Ordinary edits keep the profile complete when all required values remain valid; clearing a required field is rejected. A later schema change must use a new version and an explicit rollout rather than silently changing version 1.

Signup, resume, and existing-account flow

  • Email/password signup, when enabled, asks only email and the authentication credential plus the required account/privacy acknowledgement. Supported social signup asks no profile fields before provider return.
  • A new account is not eligible for profile completion, Slack, or course confirmation until email ownership is verified. Use the verification-first safe default from Decision: Confirm verified-email registration semantics #17; do not create a confirmed course registration for an unverified address.
  • After verification/sign-in, preserve a server-side, path-only intent: community/Slack, a specific active registration campaign, or ordinary account settings. Do not put email, invitation secrets, auth tokens, or an external next URL in the location.
  • /accounts/profile/ is the accessible HTML create/edit/resume surface. GET/PATCH /api/v1/me/profile is the session-authenticated self-service JSON surface; PATCH requires CSRF, an allowlisted schema, current revision/If-Match, and the same service/validation as HTML. GET includes required_fields, missing_fields, completion_version, completed_at, and revision.
  • /accounts/community/slack/ is the authenticated Slack-access status/reveal surface. Login, logout, password/recovery, account settings, and existing learner dashboards are never blocked by an incomplete profile.
  • Incomplete existing accounts receive a non-blocking account-settings prompt. Completion is required only before first Slack access and each new course registration; it does not cancel an existing enrollment or hide historical course work.
  • Refresh, back, duplicate submit, social callback, expired verification, and a second browser resume deterministically at the first incomplete step without duplicate account/profile/registration/delivery rows.

A new course registration requires the verified durable account. The flow is account ownership -> shared profile -> course-specific step -> confirmation. It does not create an anonymous CourseRegistration before verification.

The course-specific step shows the selected course/cohort/campaign, reuses the profile values without asking them again, and asks only:

  • the existing optional course-specific comment/goal, capped at 1,000 characters and blank for every new registration;
  • the versioned course-registration privacy acknowledgement;
  • a separate optional, unchecked newsletter/marketing consent when that integration is enabled.

Marketing consent is never required for registration or inferred from account, Slack, or course participation. Preserve historical accepted_newsletter evidence, but stop presenting it as a required bundled agreement.

Migration and source precedence

Create the canonical MemberProfile and migrate without deleting or renumbering CustomUser, CourseRegistration, enrollment, social, or account records.

For each account:

  1. Existing non-empty CustomUser profile fields seed the corresponding MemberProfile fields.
  2. A blank canonical field may be suggested from the most recent linked CourseRegistration, ordered by created_at then primary key. Registration company may seed organization.
  3. Any conflict is reported; a non-empty account value is never overwritten by registration history.
  4. Migrated/suggested values are marked unconfirmed and cannot satisfy completion until the member submits the profile.
  5. CloudFront country is a UI suggestion only and has the lowest precedence; it is never a migration source.
  6. Once MemberProfile is active, compatibility fields are projections written by the shared service, not competing authorities. Direct adapter writes fail tests.

The migration is idempotent and reports only aggregate counts and synthetic/test identifiers. Production PII does not enter local fixtures, logs, screenshots, issue comments, or migration evidence.

Country suggestion and trust

When #109 provides the trusted CloudFront signal:

  • edge/origin infrastructure removes any viewer-supplied lookalike and forwards the genuine CloudFront-Viewer-Country only across the protected CloudFront-to-origin boundary;
  • Django accepts it only when trusted-edge mode is configured, validates an uppercase ISO alpha-2 value, maps only known codes, and exposes a request-scoped suggestion to the form service;
  • the form visibly labels it as a suggestion and requires the member to confirm or replace it;
  • unknown/special codes, VPN/proxy behavior, local development, missing headers, and direct-origin/test requests produce no suggestion;
  • raw request IP and the raw header are not stored in MemberProfile or ordinary logs.

#109 improves this convenience but is not a functional blocker: onboarding works with an empty suggestion.

Immutable course-registration snapshots

At successful registration, store one immutable shared-profile snapshot with:

  • profile UUID, completion schema version, profile revision, and snapshot timestamp;
  • certificate/display name when present;
  • country code plus derived region;
  • organization, work status, professional role, and seniority.

Do not copy bio, ambitions, why-joined text, or social/profile links into registration rows: they are not required for cohort reporting or delivery. Registration owns its course-specific comment, notice/consent evidence, target campaign/cohort snapshot, and normalized email snapshot.

A later profile edit updates future prefills only. It never rewrites an earlier registration snapshot. Repointing a campaign also never rewrites history. Legal deletion/anonymization under the privacy workflow is the only exception to ordinary immutability and must leave non-PII reconciliation evidence. #54 maps this contract to final Course -> Cohort uniqueness without changing its historical meaning.

Slack access and delivery

MVP uses the existing shared DataTalks.Club Slack join URL. It does not call a Slack invitation API and has no manual review queue.

  • Store the join URL only in the approved secret store/runtime secret channel. Never store it in MemberProfile, Slack eligibility rows, EmailDelivery context/rendered-body retention, audit, logs, metrics, URLs, OpenAPI examples, screenshots, or issue evidence.
  • Maintain a non-secret invite_version. A successful version-1 profile completion atomically creates or confirms one SlackAccessGrant and one unique EmailDelivery intent keyed by account, profile completion version, and invite_version.
  • The worker resolves the current secret only at send time, renders the fixed transactional template without retaining the secret-bearing body, and sends only to the verified account email after commit.
  • Profile saving and eligibility remain committed if the worker/provider is unavailable. Delivery follows Build the durable Relay delivery intent and status lifecycle #49 lease/retry/suppression/ambiguous/manual-resend semantics.
  • An eligible member may reveal the current link on /accounts/community/slack/. Every response is authenticated, private, no-store, noindex, and referrer-safe. If the secret is unavailable, show a safe delayed/support state without rolling back completion.
  • Duplicate submit, refresh, worker retry, and restart create no duplicate logical delivery. Invite rotation increments invite_version; an eligible member can receive/reveal the new version without re-entering profile fields.
  • Ordinary profile edits do not revoke a delivered invitation. Account quarantine/disablement/deletion denies future reveal/resend and cancels unsent work where safe; an already used external Slack membership cannot be revoked by this application and the UI must not claim otherwise.

A member has no self-service email-resend button in MVP; revisiting the reveal page remains available. Authorized staff may create a distinct audited resend with rate limits and a reason.

Studio and admin API

Add a Members section and capability-registry entries with exact adapter parity:

  • list/search completion and Slack delivery state: GET /studio/members/ and GET /api/v1/admin/member-profiles;
  • inspect one profile/grant/delivery summary: GET /studio/members// and GET /api/v1/admin/member-profiles/;
  • correct allowlisted profile fields with reason and revision: POST in Studio and PATCH /api/v1/admin/member-profiles/ with If-Match;
  • resend the current Slack-link delivery: confirmed POST in Studio and POST /api/v1/admin/member-profiles//slack-resend with Idempotency-Key.

Use explicit accounts.member_profile.view_pii, accounts.member_profile.correct, and accounts.slack_access.manage capabilities. Support users see masked identifiers by default; full PII requires the dedicated permission. Searches are bounded and do not expose object existence outside the authorized queryset. No bulk profile export is added in this issue.

Staff correction uses the same accounts service, advances revision, records actor/reason/changed field names, and never places old/new free text, URLs, email, country suggestion, or Slack link in audit. List/detail/resend never return the raw join link. Django admin remains separately protected break-glass support and must present MemberProfile separately from content Person.

Privacy, caching, export, and deletion

  • MemberProfile and every onboarding/self/Studio/admin response are private, no-store, noindex and absent from sitemap/search/public serializers.
  • Account export includes the member profile, completion/grant metadata, and the members own registration snapshots; it does not disclose the shared Slack secret.
  • Account correction uses the normal service. Deletion/anonymization removes or anonymizes profile PII, future Slack eligibility, compatibility projections, search/cache/export copies, and queued deliveries according to the privacy workflow.
  • Registration snapshots follow the approved educational-record retention; profile deletion does not silently rewrite them outside that workflow.
  • No analytics event contains field values. Metrics use aggregate step, completion version, delivery state, and safe failure category only.

Non-goals

  • No public member directory/page, Person creation/link inference, speaker/guest/author role, staff permission, profile ranking, recommendation, or social graph.
  • No custom profile fields/taxonomy editor, arbitrary links, avatar upload, organization directory, capacity/waitlist, marketing campaign, Slack SCIM/member synchronization, Slack invitation API, or review queue.
  • No replacement user/login model, production database import, broad registration redesign, Course -> Cohort structural migration, or removal of compatibility columns.
  • No synchronous external URL validation and no secret in evidence.

Acceptance criteria

  • The exact version-1 field/choice/length/required rules above are enforced through one accounts service and identical HTML, self API, Studio, and admin API validation.
  • MemberProfile is one-to-one with the preserved CustomUser and cannot create/link/update Person or infer editorial roles/permissions.
  • Minimal signup and social return collect no profile fields before verified ownership; intent/resume works without unsafe next values or duplicate accounts.
  • Existing incomplete accounts retain login/settings/dashboard/history and are gated only at new Slack access or new course registration.
  • Migration precedence preserves every adopted value and registration row, reports conflicts, requires member confirmation, and establishes MemberProfile as the single authority with tested compatibility projections.
  • Country suggestion is visible/editable/unconfirmed, accepts only the trusted validated edge value, stores no IP/raw header, and degrades normally without Cache anonymous public traffic safely at CloudFront #109.
  • New course registration does not repeat shared questions, snapshots only the exact reporting fields above, preserves course-specific/notice/optional-consent evidence, and never changes earlier snapshots after profile edits.
  • Profile completion and Slack grant/delivery intent commit atomically; delivery is asynchronous/idempotent, safe under outage/ambiguity/suppression, and never blocks the saved profile.
  • Slack reveal/email/resend/rotation/quarantine/deletion follow the exact secret and lifecycle rules with no raw link outside the eligible authenticated response or provider submission.
  • Self API and management adapters are allowlisted, revision-safe, CSRF/permission protected, redacted, private/no-store, and use the same service; Studio/admin API parity and OpenAPI drift checks pass.
  • Export/deletion/retention and aggregate observability cover every new field/state without leaking profile text, links, email, IP, country header, or invitation secret.
  • Migrations, SQLite-focused tests, PostgreSQL constraints/concurrency tests where needed, focused Django/security/parity/OpenAPI tests, full compatible course tests, and core Playwright pass through uv/Make targets.
  • Templates are readable, non-minified, accessible at WCAG 2.2 AA critical-flow level, and visually verified at desktop/mobile.
  • [HUMAN] An authorized operator configures the real invite secret without exposing it, then a synthetic verified member completes the deployed flow, receives/reveals a working link, and confirms no secret/PII in evidence.

Django, integration, and failure scenarios

  1. Required/optional boundary for every work status, role, seniority, whitespace/length/Unicode, URL scheme/userinfo, unknown choice, stale revision, mass assignment, and direct POST completion bypass.
  2. Minimal password signup, social signup/return, existing account, unverified/expired verification, duplicate normalized email, safe intent, refresh/back/double-submit, second-browser resume, and logout.
  3. Missing/valid/unknown/forged country header, direct origin/test mode, manual replacement, VPN explanation, and confirmation persistence.
  4. Migration from account-only, registration-only, conflicting, blank, legacy-role, multiple-registration, duplicate-reconciled, and incomplete accounts; run dry-run/apply twice and compare counts/checksums.
  5. Anonymous course entry -> verify -> profile -> course-specific registration; returning complete/incomplete account; second course registration; concurrent duplicate; campaign repoint; profile edit after registration; legal anonymization.
  6. First completion, rollback before commit, worker down, duplicate intent, lease expiry, transient/permanent/suppressed/ambiguous provider states, manual resend, invite rotation, quarantine, disablement, and deletion with no real provider/Slack call.
  7. Owner/self vs another account; support masked/full PII; authorized/denied correction and resend; stale/replay/idempotency; audit and artifact canary scans.
  8. Assert zero Person write/query side effect from signup, profile, Slack, course registration, migration, Studio correction, export, and deletion.

Browser acceptance

At desktop about 1440x900 and mobile about 390x844 with synthetic identities:

  1. Start at Join Slack as anonymous, complete minimal verification, return to the profile, exercise accessible error summary/focus/preserved values, complete it, and reach the private Slack confirmation without capturing the link.
  2. Start at a registration campaign as a new and returning member; verify shared values are not asked twice, country suggestion is visibly editable, course comment/privacy/optional marketing are distinct, and refresh/back/duplicate submit is stable.
  3. Edit profile settings, register for a second course with updated prefill, and prove the first registration summary remains its old snapshot.
  4. Exercise missing edge country, social callback, incomplete-account prompt, secret-unavailable/delivery-delayed, quarantine, and safe denial states.
  5. In Studio, inspect masked member state, use an authorized correction/resend confirmation, test a denied role and stale revision, and verify People remains a separate editorial section.
  6. Inspect screenshots and browser history/network/console for responsive layout, no debug/error page, no raw email/profile text outside the intended view, and no Slack secret in URL/title/history/screenshot/artifacts.

Required specification amendments

Implementation must update these exact authority files in the same issue:

  • _docs/architecture/app-boundaries.md — accounts ownership of MemberProfile and scalar coordination with email_app;
  • _docs/specs/01-platform-architecture.md — database ownership and onboarding/outbox request flow;
  • _docs/specs/03-github-content-and-people.md — explicit MemberProfile/account versus Person boundary;
  • _docs/specs/04-courses-and-cohorts.md — shared-profile reuse, course-specific questions, and immutable snapshot fields;
  • _docs/specs/05-events-registration-email.md — Slack-link transactional purpose and secret-safe render/delivery semantics;
  • _docs/specs/06-studio-and-admin-api.md — Members section, self API boundary, capabilities, routes, masking, correction, and resend;
  • _docs/specs/07-security-privacy-operations.md — profile PII, edge-country trust, export/deletion/retention, cache and logging rules;
  • _docs/specs/09-migration-rollout-roadmap.md — expand-and-contract ordering and reconciliation;
  • _docs/specs/10-verification-strategy.md — onboarding/Slack/snapshot/browser/failure scenarios;
  • _docs/specs/open-decisions.md — record the resolved MVP defaults: immediate automated eligibility, shared secret-stored join URL, no Slack API/review queue, and code-defined taxonomies.

Dependencies and delivery

Because the real Slack secret and deployed link check are HUMAN, implementation commits use Refs #108, retain/add the human label, and leave the issue open until the final HUMAN criterion is verified.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P0Must-have or release-blockingadminArea: adminauthArea: authcoursesArea: coursesdata-migrationArea: data-migrationemailArea: emailenhancementNew feature or requestfrontendArea: frontendhumanCode complete; manual verification requiredintegrationArea: integrationsecurityArea: securitytestingArea: testing

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions