Files
Moheeb Zara 30bba045f8 feat: configurable member onboarding + UX/persona research
A new member who joins via invite code now walks through an
admin-defined onboarding sequence before reaching the app.

Schema (migration 022, folded into schema.sql Section 14):
- space_onboarding_steps: step_key, step_type
  (welcome|code_of_conduct|profile|payment|content), title, body,
  config jsonb, is_enabled, is_required, is_system, sort_order,
  unique per (space, step_key). Full RLS: members read, admin/board
  write, admin delete non-system.
- space_members.onboarding_completed_at + onboarding_progress jsonb.
- Trigger seeds four default steps per space (welcome, code of
  conduct [required], profile, dues). Backfill seeds existing spaces
  and marks all existing members complete so the new gate never
  traps them.

Flow:
- /onboarding (top-level route, outside the (app) group so no
  redirect loop). Progress bar, step counter, back/continue, per-type
  rendering. Profile step calls updateMyProfile; payment step is a
  non-blocking nudge with optional link and "Remind me later".
- (app)/layout.tsx redirects to /onboarding while
  onboarding_completed_at is null.
- createSpace marks the founder complete: founders configure the
  space, they do not run the member flow (matches admin-vs-end-user
  persona research).
- finishOnboarding re-checks required steps server-side so a tampered
  client cannot skip a required code-of-conduct acknowledgement.
  skipOnboarding only works when nothing is required.

Admin config:
- Settings -> Onboarding tab: reorder, edit title/body/payment link,
  toggle enabled/required, add custom content steps, delete
  non-system steps.

Safety:
- components/safe-markdown.tsx renders admin-authored markdown and a
  sanitized subset of HTML (rehype-raw + rehype-sanitize). Added
  those two deps.

Also:
- bio added to updateMyProfileSchema (members can self-set it; not
  blocked by the migration-015 trigger).
- docs/UX_AND_PERSONAS.md: 4 personas and prioritized UX audit
  findings. Onboarding was finding #1; 2-6 deferred and tracked.

266/266 unit tests pass. Production build clean, /onboarding in the
route table. Migration 022 applies idempotently on the next deploy.
2026-05-15 15:38:40 -07:00

138 lines
6.8 KiB
PL/PgSQL

-- =============================================================================
-- 022: Configurable member onboarding
-- =============================================================================
-- A space defines an ordered list of onboarding steps. A member who joins
-- walks through the enabled steps before they land on the dashboard. Founders
-- (created via createSpace) skip member onboarding: their onboarding is the
-- admin configuration surface, not this flow.
--
-- Step types:
-- welcome info screen (markdown body), never blocking
-- code_of_conduct markdown body + required acknowledgement checkbox
-- profile prompts the member to fill display_name/handle/bio/skills
-- payment dues setup nudge: markdown body + optional link button
-- content fully custom sanitized markdown/HTML, optional ack
--
-- Built-in steps are is_system = true: they can be disabled and reordered but
-- not deleted. Custom content steps are is_system = false and deletable.
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.space_onboarding_steps (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
step_key text NOT NULL CHECK (char_length(step_key) BETWEEN 1 AND 60),
step_type text NOT NULL CHECK (step_type IN ('welcome','code_of_conduct','profile','payment','content')),
title text NOT NULL CHECK (char_length(title) BETWEEN 1 AND 200),
body text,
config jsonb NOT NULL DEFAULT '{}',
is_enabled boolean NOT NULL DEFAULT true,
is_required boolean NOT NULL DEFAULT false,
is_system boolean NOT NULL DEFAULT false,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, step_key)
);
CREATE INDEX IF NOT EXISTS idx_onboarding_steps_space
ON public.space_onboarding_steps (space_id, sort_order);
ALTER TABLE public.space_members
ADD COLUMN IF NOT EXISTS onboarding_completed_at timestamptz;
ALTER TABLE public.space_members
ADD COLUMN IF NOT EXISTS onboarding_progress jsonb NOT NULL DEFAULT '{}';
-- Seed the four default steps on space creation.
CREATE OR REPLACE FUNCTION public.seed_default_onboarding_steps()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO public.space_onboarding_steps
(space_id, step_key, step_type, title, body, config, is_enabled, is_required, is_system, sort_order)
VALUES
(NEW.id, 'welcome', 'welcome',
'Welcome to ' || NEW.name,
E'We are glad you are here.\n\nThis short setup gets you ready to use the space. It takes about a minute.',
'{}'::jsonb, true, false, true, 0),
(NEW.id, 'code_of_conduct', 'code_of_conduct',
'Code of Conduct',
E'Be excellent to each other.\n\n- Treat people and tools with respect.\n- Clean up after yourself.\n- Ask before using equipment you have not been trained on.\n\nAn admin can edit this text in Settings, Onboarding.',
'{"require_ack": true, "ack_label": "I have read and agree to the code of conduct"}'::jsonb,
true, true, true, 1),
(NEW.id, 'profile', 'profile',
'Complete your profile',
E'Tell the space who you are. You can change this anytime from your profile.',
'{}'::jsonb, true, false, true, 2),
(NEW.id, 'payment', 'payment',
'Set up your dues',
E'Membership dues keep the space running. Set up your recurring payment now so you do not lose access.\n\nAn admin can put the payment link and instructions here in Settings, Onboarding.',
'{}'::jsonb, true, false, true, 3)
ON CONFLICT (space_id, step_key) DO NOTHING;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_seed_default_onboarding_steps ON public.spaces;
CREATE TRIGGER trg_seed_default_onboarding_steps
AFTER INSERT ON public.spaces
FOR EACH ROW EXECUTE FUNCTION public.seed_default_onboarding_steps();
-- Backfill default steps for existing spaces.
INSERT INTO public.space_onboarding_steps
(space_id, step_key, step_type, title, body, config, is_enabled, is_required, is_system, sort_order)
SELECT
s.id,
d.step_key,
d.step_type,
CASE WHEN d.step_key = 'welcome' THEN 'Welcome to ' || s.name ELSE d.title END,
d.body,
d.config::jsonb,
true,
d.is_required,
true,
d.sort_order
FROM public.spaces s
CROSS JOIN LATERAL (
VALUES
('welcome', 'welcome', 'Welcome',
'We are glad you are here. This short setup gets you ready to use the space.',
'{}', false, 0),
('code_of_conduct', 'code_of_conduct', 'Code of Conduct',
'Be excellent to each other. An admin can edit this in Settings, Onboarding.',
'{"require_ack": true, "ack_label": "I have read and agree to the code of conduct"}', true, 1),
('profile', 'profile', 'Complete your profile',
'Tell the space who you are.',
'{}', false, 2),
('payment', 'payment', 'Set up your dues',
'Membership dues keep the space running. An admin can add the payment link in Settings, Onboarding.',
'{}', false, 3)
) AS d(step_key, step_type, title, body, config, is_required, sort_order)
ON CONFLICT (space_id, step_key) DO NOTHING;
-- Existing members have already been using the app: mark their onboarding done
-- so the new gate does not trap them.
UPDATE public.space_members
SET onboarding_completed_at = now()
WHERE onboarding_completed_at IS NULL;
ALTER TABLE public.space_onboarding_steps ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS onboarding_steps_select ON public.space_onboarding_steps;
DROP POLICY IF EXISTS onboarding_steps_insert ON public.space_onboarding_steps;
DROP POLICY IF EXISTS onboarding_steps_update ON public.space_onboarding_steps;
DROP POLICY IF EXISTS onboarding_steps_delete ON public.space_onboarding_steps;
-- Any member of the space can read steps (the joining member needs them).
CREATE POLICY onboarding_steps_select ON public.space_onboarding_steps FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY onboarding_steps_insert ON public.space_onboarding_steps FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY onboarding_steps_update ON public.space_onboarding_steps FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY onboarding_steps_delete ON public.space_onboarding_steps FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']) AND NOT is_system);
DROP TRIGGER IF EXISTS trg_onboarding_steps_touch ON public.space_onboarding_steps;
CREATE TRIGGER trg_onboarding_steps_touch
BEFORE UPDATE ON public.space_onboarding_steps
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();