Add memo engagement, Local Docker-Compose rig (backend)#38
Conversation
xrendan
left a comment
There was a problem hiding this comment.
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.
| module Api | ||
| module V1 | ||
| module Auth | ||
| class LinkedinController < ActionController::Base |
There was a problem hiding this comment.
As a critiquer/endorser, do you have to re-enter your postal code information each time you critique/endorse a memo?
There was a problem hiding this comment.
Because we're storing that on the ticket that gets passed to the browser generally.
There was a problem hiding this comment.
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?
| -- Name: critiques; Type: TABLE; Schema: public; Owner: - | ||
| -- | ||
|
|
||
| CREATE TABLE public.critiques ( |
There was a problem hiding this comment.
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.
fc105bd to
869e856
Compare
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
Endorsement,Critique(+ sharedLinkedinVerifiedconcern). Postal code validated against the Canadian format and normalized toA1A 1A1.memos:endorsements_count(built-in counter cache) andapproved_critiques_count(callback-driven, only counts approved).id_token. Mints a 15-minute HS256 "verification ticket" the frontend submits with the engagement form. Client never seesclient_secretorid_token.app/services/linkedin_oidc.rbapp/services/verification_ticket.rbapp/controllers/api/v1/auth/linkedin_controller.rb+ callback view thatpostMessages to a configured frontend originPOST /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 topending)GET /api/v1/memos/:slug/critiques(onlyapproved, paginated)full: true) now includesendorsements_count,critiques_count,recent_endorsers(top 5, name + created_at), andcritiques(top 20 approved).Admin::CritiquesControllerwith index/show/approve/reject/destroy, sidebar link with pending-count badge.RevalidateMemoJob(Solid Queue) callsNextjsRevalidatorafterEndorsementcreate/destroy and after aCritiquecrosses theapprovedboundary. Silently no-ops if the env vars aren't set.DB schema changes
Three new migrations:
20260510000001_create_endorsements.rbendorsements—memo_id(FK),linkedin_sub,name,given_name,family_name,email,email_verified(bool),picture_url,postal_code, timestamps[memo_id, linkedin_sub];[memo_id, created_at]20260510000002_create_critiques.rbcritiques— same identity columns +body:text,status:integer(enum: pending/approved/rejected, default 0, NOT NULL),published_at,moderated_by_id(FK → users),moderated_at[memo_id, linkedin_sub];[memo_id, status, created_at]20260510000003_add_engagement_counters_to_memos.rbmemos.endorsements_countandmemos.approved_critiques_count(bothinteger, NOT NULL, default 0)Memo.find_each(&:reset_counters)and a count query for approved critiquesNo table renames or destructive changes. Safe under online migration (only
add_column/add_indexon small tables).LinkedIn app registration (one-time, before deploy)
https://yorkfactory.buildcanada.com/api/v1/auth/linkedin/callbackhttps://<staging-host>/api/v1/auth/linkedin/callbackhttp://localhost:3000/api/v1/auth/linkedin/callbackLINKEDIN_CLIENT_IDandLINKEDIN_CLIENT_SECRET.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
.envlocally):LINKEDIN_CLIENT_ID86abcd1234efghLINKEDIN_CLIENT_SECRETLINKEDIN_REDIRECT_URIhttps://yorkfactory.buildcanada.com/api/v1/auth/linkedin/callbackLINKEDIN_POSTMESSAGE_ORIGINhttps://buildcanada.compostMessagetoNEXTJS_REVALIDATE_URLhttps://buildcanada.com/api/revalidateNEXTJS_REVALIDATE_SECRETREVALIDATE_SECRETExisting
CORS_ORIGINSalready permitsPOSTfrom the TradingPost origin (config/initializers/cors.rballows all methods for/api/v1/*). No change needed there.Deployment
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.rbCovers postal-code regex normalization, uniqueness scoping, status transitions, and counter-cache callbacks.
Manual end-to-end (after deploy)
Migrations applied:
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).Endorsement create (using a ticket captured from step 2):
Expect
201and aname/created_atbody.Memo.find_by(slug: ...).endorsements_countincrements.Duplicate: re-run the same
curl→409 { "error": "already_submitted", ... }.Critique create: same call against
/critiqueswithbodyset. Expect201withstatus: "pending". PublicGET /api/v1/memos/<slug>does not include it.Approve in admin:
/admin/critiques→ click the pending row → Approve. PublicGET /api/v1/memos/<slug>now includes the critique andcritiques_countis +1. Sidebar pending badge decrements.Cache bust: tail TradingPost logs while approving. You should see a
POST /api/revalidatefrom york_factory within a second (assumingNEXTJS_REVALIDATE_URL/NEXTJS_REVALIDATE_SECRETare configured).Security spot-checks
POST /api/v1/memos/<slug>/endorsementswith a forged or randomverified_ticket→401.401.kindormemo_slug→401.postMessageshould target onlyLINKEDIN_POSTMESSAGE_ORIGIN.Rollback
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