Skip to content

Add memo engagement, Local Docker-Compose rig (backend)#38

Open
jnmclarty wants to merge 11 commits into
BuildCanada:mainfrom
jnmclarty:jeff/add-memo-engagement
Open

Add memo engagement, Local Docker-Compose rig (backend)#38
jnmclarty wants to merge 11 commits into
BuildCanada:mainfrom
jnmclarty:jeff/add-memo-engagement

Conversation

@jnmclarty

Copy link
Copy Markdown

Add memo endorsements and critiques (LinkedIn-verified)

Adds a public engagement layer to memos. Readers verify their identity via LinkedIn OIDC and can either endorse a memo (one-click) or critique it (with body, admin-moderated). Counts and the 5 most recent endorsers/approved critiques are embedded in the memo show payload. Triggers Next.js cache revalidation on TradingPost when engagement changes.

What's in this PR

  • Models: Endorsement, Critique (+ shared LinkedinVerified concern). Postal code validated against the Canadian format and normalized to A1A 1A1.
  • Counter caches on memos: endorsements_count (built-in counter cache) and approved_critiques_count (callback-driven, only counts approved).
  • LinkedIn OIDC: server-side authorization-code flow with JWKS verification of the id_token. Mints a 15-minute HS256 "verification ticket" the frontend submits with the engagement form. Client never sees client_secret or id_token.
    • app/services/linkedin_oidc.rb
    • app/services/verification_ticket.rb
    • app/controllers/api/v1/auth/linkedin_controller.rb + callback view that postMessages to a configured frontend origin
  • API endpoints (nested under existing memos):
    • POST /api/v1/memos/:slug/endorsements (create, dedup via unique index → 409)
    • GET /api/v1/memos/:slug/endorsements (paginated)
    • POST /api/v1/memos/:slug/critiques (create — defaults to pending)
    • GET /api/v1/memos/:slug/critiques (only approved, paginated)
  • Memo serializer (full: true) now includes endorsements_count, critiques_count, recent_endorsers (top 5, name + created_at), and critiques (top 20 approved).
  • Admin moderation: Admin::CritiquesController with index/show/approve/reject/destroy, sidebar link with pending-count badge.
  • Cache busting: RevalidateMemoJob (Solid Queue) calls NextjsRevalidator after Endorsement create/destroy and after a Critique crosses the approved boundary. Silently no-ops if the env vars aren't set.

DB schema changes

Three new migrations:

  1. 20260510000001_create_endorsements.rb
    • Table: endorsementsmemo_id (FK), linkedin_sub, name, given_name, family_name, email, email_verified (bool), picture_url, postal_code, timestamps
    • Indexes: unique [memo_id, linkedin_sub]; [memo_id, created_at]
  2. 20260510000002_create_critiques.rb
    • Table: critiques — same identity columns + body:text, status:integer (enum: pending/approved/rejected, default 0, NOT NULL), published_at, moderated_by_id (FK → users), moderated_at
    • Indexes: unique [memo_id, linkedin_sub]; [memo_id, status, created_at]
  3. 20260510000003_add_engagement_counters_to_memos.rb
    • Adds memos.endorsements_count and memos.approved_critiques_count (both integer, NOT NULL, default 0)
    • Backfills using Memo.find_each(&:reset_counters) and a count query for approved critiques

No table renames or destructive changes. Safe under online migration (only add_column/add_index on small tables).

LinkedIn app registration (one-time, before deploy)

  1. Go to https://www.linkedin.com/developers/apps and create an app under the Build Canada org (or use an existing one).
  2. Under Products, request access to Sign In with LinkedIn using OpenID Connect. Approval is automatic / instant.
  3. Under Auth → OAuth 2.0 settings, add Authorized redirect URLs:
    • Production: https://yorkfactory.buildcanada.com/api/v1/auth/linkedin/callback
    • Staging (if applicable): https://<staging-host>/api/v1/auth/linkedin/callback
    • Local dev: http://localhost:3000/api/v1/auth/linkedin/callback
  4. Under Auth → Application credentials, copy Client ID and Primary Client Secret. These become LINKEDIN_CLIENT_ID and LINKEDIN_CLIENT_SECRET.
  5. Confirm the requested scopes (openid, profile, email) are enabled — they are part of the Sign In with LinkedIn using OpenID Connect product.

New environment variables

Add to Kamal secrets (and .env locally):

Variable Example Purpose
LINKEDIN_CLIENT_ID 86abcd1234efgh LinkedIn app client ID
LINKEDIN_CLIENT_SECRET (secret) LinkedIn app client secret
LINKEDIN_REDIRECT_URI https://yorkfactory.buildcanada.com/api/v1/auth/linkedin/callback Must match a redirect URL registered with the LinkedIn app
LINKEDIN_POSTMESSAGE_ORIGIN https://buildcanada.com Trusted frontend origin the popup will postMessage to
NEXTJS_REVALIDATE_URL https://buildcanada.com/api/revalidate TradingPost endpoint that busts memo cache tags
NEXTJS_REVALIDATE_SECRET (secret) Bearer token; must match TradingPost's REVALIDATE_SECRET

Existing CORS_ORIGINS already permits POST from the TradingPost origin (config/initializers/cors.rb allows all methods for /api/v1/*). No change needed there.

Deployment

# 1. Set new env vars in Kamal secrets (do NOT commit values)
kamal secrets push

# 2. Deploy
kamal deploy

# 3. Run migrations on a release host
kamal app exec --reuse 'bin/rails db:migrate'

# 4. Smoke-check
kamal app exec --reuse 'bin/rails runner "puts Endorsement.count, Critique.count, Memo.first.endorsements_count"'

If anything goes sideways, the migrations are reversible (db:rollback STEP=3) and the new endpoints are additive — existing memo APIs are unchanged.

Testing

Unit / model

bin/rails test test/models/endorsement_test.rb test/models/critique_test.rb

Covers postal-code regex normalization, uniqueness scoping, status transitions, and counter-cache callbacks.

Manual end-to-end (after deploy)

  1. Migrations applied:

    Endorsement.column_names.include?("linkedin_sub")  # => true
    Critique.statuses                                  # => {"pending"=>0, "approved"=>1, "rejected"=>2}
    Memo.first.has_attribute?(:endorsements_count)     # => true
  2. LinkedIn flow: open
    https://yorkfactory.buildcanada.com/api/v1/auth/linkedin/start?kind=endorsement&memo_slug=<real-slug>
    in a browser. Should 302 to LinkedIn, then return to the callback page which postMessages the verification ticket and closes itself (visible in DevTools → Application → Console of the opener).

  3. Endorsement create (using a ticket captured from step 2):

    curl -X POST https://yorkfactory.buildcanada.com/api/v1/memos/<slug>/endorsements \
      -H 'Content-Type: application/json' \
      -d '{"verified_ticket":"<ticket>","postal_code":"M5V 3A8"}'

    Expect 201 and a name/created_at body. Memo.find_by(slug: ...).endorsements_count increments.

  4. Duplicate: re-run the same curl409 { "error": "already_submitted", ... }.

  5. Critique create: same call against /critiques with body set. Expect 201 with status: "pending". Public GET /api/v1/memos/<slug> does not include it.

  6. Approve in admin: /admin/critiques → click the pending row → Approve. Public GET /api/v1/memos/<slug> now includes the critique and critiques_count is +1. Sidebar pending badge decrements.

  7. Cache bust: tail TradingPost logs while approving. You should see a POST /api/revalidate from york_factory within a second (assuming NEXTJS_REVALIDATE_URL/NEXTJS_REVALIDATE_SECRET are configured).

Security spot-checks

  • POST /api/v1/memos/<slug>/endorsements with a forged or random verified_ticket401.
  • Reuse a ticket after 15 minutes → 401.
  • Submit a ticket with mismatched kind or memo_slug401.
  • The callback view's postMessage should target only LINKEDIN_POSTMESSAGE_ORIGIN.

Rollback

kamal app exec --reuse 'bin/rails db:rollback STEP=3'
git revert <merge-commit>
kamal deploy

Endpoints become 404; data is dropped. The TradingPost frontend will degrade gracefully (counts default to 0, lists empty) since fields are read with ?? 0 / ?? [].

Screenshots

image image

@xrendan xrendan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really do think we want to create users for each of these people, but I know we don't have that yet and I don't want really want to increase scope here.

I think instead of creating a verification ticket, creating a user and passing an encrypted/signed jwt with the user would be better here and get us closer to that end-state without completely blowing up the scope of this PR.

I have not looked at what the frontend looks like yet.

Comment thread app/services/nextjs_revalidator.rb Outdated
Comment thread app/services/linkedin_oidc.rb Outdated
Comment thread app/services/linkedin_oidc.rb
module Api
module V1
module Auth
class LinkedinController < ActionController::Base

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a critiquer/endorser, do you have to re-enter your postal code information each time you critique/endorse a memo?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because we're storing that on the ticket that gets passed to the browser generally.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yah, you'd have to re-enter each time.

Storing that, imho, would kick off the first-class "User"-object, imho.

So, I reduced the friction by storing it in local storage client side. Is that a fair balance?

4342e96

Comment thread db/structure.sql Outdated
-- Name: critiques; Type: TABLE; Schema: public; Owner: -
--

CREATE TABLE public.critiques (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually think this and the endorsement tables should just be the same table with STI.

We may want to moderate endorsements (bots?) and maybe we want to iterate on types of engagements (which we probably don't want an entirely different table for.

@jnmclarty jnmclarty force-pushed the jeff/add-memo-engagement branch from fc105bd to 869e856 Compare June 17, 2026 22:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants