User Membership Integration + Volunteer#13
Conversation
There was a problem hiding this comment.
Pull request overview
Adds Stripe-backed memberships and volunteer application support to the Strapi CMS, plus an event-request approval flow that creates/publishes linked events and tracks approval status.
Changes:
- Introduces a new
membershipcontent-type with Stripe checkout, portal, and webhook handling. - Extends the users-permissions user model with
userType,stripeCustomerId, andmemberships, plus new/volunteer/applyand/meendpoints. - Updates event-request handling to create a draft event on submission and support approval/publish workflows with status tracking.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| cms/types/generated/contentTypes.d.ts | Updates generated Strapi type declarations for new/changed content-types and user fields. |
| cms/src/middlewares/stripe-webhook.ts | Adds a middleware to preserve Stripe webhook raw body for signature verification. |
| cms/src/index.ts | Adds a documents middleware to sync event-request status (and notify) on event publish. |
| cms/src/extensions/users-permissions/strapi-server.ts | Extends user schema (stripe/customer + memberships + userType) and registers new controllers. |
| cms/src/extensions/users-permissions/routes/custom-routes.ts | Adds a custom users-permissions route for volunteer applications. |
| cms/src/extensions/users-permissions/controllers/user.ts | Implements me and volunteerApply endpoints and related validation/email logic. |
| cms/src/api/membership/services/membership.ts | Adds the core service for the new membership content-type. |
| cms/src/api/membership/routes/membership.ts | Adds membership routes for “me”, checkout, portal, and Stripe webhook. |
| cms/src/api/membership/controllers/membership.ts | Implements Stripe checkout/portal creation and webhook handlers to create/update memberships. |
| cms/src/api/membership/content-types/membership/schema.json | Defines the new Membership collection type schema. |
| cms/src/api/event-request/routes/event-request.ts | Adds an approve route for event-requests. |
| cms/src/api/event-request/controllers/form.ts | Creates a draft Event on event-request submission and links it back to the request. |
| cms/src/api/event-request/controllers/event-request.ts | Implements event-request approval (publishing the linked event + updating request status). |
| cms/src/api/event-request/content-types/event-request/schema.json | Adds status and a one-to-one event relation to the event-request schema. |
| cms/config/middlewares.ts | Registers the global Stripe webhook raw-body middleware. |
| cms/.env.example | Documents required Stripe environment variables. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
iboshkov
left a comment
There was a problem hiding this comment.
Code review
Inline comments below cover the findings on lines this PR touches. Two more are in updateProfile in cms/src/extensions/users-permissions/controllers/user.ts, on lines outside this PR's diff, so they can't be anchored inline:
profilePicturevalidation is bypassed when the ID is sent as a number (user.ts:209): the existence/mime/size checks only runif (typeof safeData.profilePicture === 'string'), yet the declared body type isprofilePicture?: number. A numeric ID (what a normal JSON client sends) skips validation entirely. There is also no ownership check, so any authenticated user can attach any media file ID in the upload library.- Empty strings bypass validation but still get written (
user.ts:185-224): guards likeif (safeData.username && ...)skip validation for"", but the value stays insafeDataand is passed to the update, so{"username": ""}blanks out a username. A non-string value (array/object) throws on.trim()and surfaces as a generic 500.
Positives: webhook signature verification against the raw body is done correctly, stripeSubscriptionId is private: true so it never leaks through the API, and the tier normalization fallbacks are sensible.
| function validateNameFields(fieldName: string, value: string) { | ||
| function validateNameFields(fieldName: string, value: string, ctx: any) { | ||
| if (typeof value !== 'string' || value.length === 0) { | ||
| throw new ValidationError(`${fieldName} cannot be empty`); |
There was a problem hiding this comment.
Missing import: ValidationError is used but never defined in this file. It is thrown 10 times (here through line 220) but there is no import. TypeScript compilation fails with Cannot find name 'ValidationError', and if the file is ever transpiled without a type check, every validation failure throws a ReferenceError at runtime; the catch block's instanceof ValidationError then also throws, so clients get a 500 instead of a 400 for any invalid input.
import { errors } from '@strapi/utils';
const { ValidationError } = errors;| import { factories } from '@strapi/strapi'; | ||
|
|
||
| export default factories.createCoreRouter('api::event-request.event-request'); No newline at end of file | ||
| export default { |
There was a problem hiding this comment.
This PR removes the custom approve controller method, but routes/custom-event-request.ts still registers POST /event-requests/:id/approve with handler event-request.approve. Strapi validates route handlers at startup, so the server throws "Handler not found" and won't boot. That route file needs to be deleted along with the controller method (the publish middleware in src/middlewares/event-request-approval.ts already covers the approval flow).
| break; | ||
| } | ||
|
|
||
| const existing = await strapi.db.query(UID).findMany({ |
There was a problem hiding this comment.
Race: duplicate memberships can be created. checkout.session.completed, customer.subscription.created, and the invoice.payment_succeeded fallback all do check-then-create keyed on stripeSubscriptionId, and Stripe delivers these events near-simultaneously with no ordering guarantee. Two handlers can both pass the "already exists" check and each insert a row. Adding "unique": true to stripeSubscriptionId in the membership schema makes the second insert fail safely.
| data: { status: 'cancelled', endDate: new Date() }, | ||
| }); | ||
|
|
||
| const user = await strapi.db.query(USER_UID).findOne({ |
There was a problem hiding this comment.
Cancelled subscriptions never demote the user - this block is dead code. Two issues compound:
- The membership was fetched above via
strapi.db.querywithoutpopulate: ['user'], somembership.userisundefinedand this lookup finds nothing. - Even if it did, the guard below checks
user?.document_id, but the query engine returns the attribute asdocumentId(camelCase).
Net effect: the membership row is marked cancelled, but the user's userType stays member forever after cancelling.
|
|
||
| case 'invoice.payment_failed': { | ||
| const failedInvoice = event.data.object; | ||
| const failedSubId = failedInvoice.subscription; |
There was a problem hiding this comment.
invoice.subscription is absent on newer Stripe API versions, so failed payments never flip the membership to pending. The invoice.payment_succeeded handler above already works around exactly this with invoice.lines?.data?.[0]?.subscription - the same fallback is needed here.
| export default (config, { strapi }) => { | ||
| return async (ctx, next) => { | ||
| if (ctx.path === '/api/memberships/webhook' && ctx.method === 'POST') { | ||
| const chunks = []; |
There was a problem hiding this comment.
Unbounded body buffering on an unauthenticated path. This buffers the entire request body into memory before any signature check or size limit applies (it runs before strapi::body), so anyone can POST arbitrarily large payloads to /api/memberships/webhook. Worth capping the buffered size (Stripe events are tiny; ~1MB is a generous cap) and aborting early when exceeded.
-Changed user model to include Membership
-Volunteer request endpoint for sending email to base42 -> Admin manually sets usertype to volunteer via Strapi Dashboard (Open for automation ideas)
-Endpoints for checking current Membership type for each user via JWT
UPDATE:
✅ STRIPE INTEGRATED WITH TEST API KEY AND TESTED
to checkout creation so webhook can reliably read the tier. Added fallback
to retrieve subscription from Stripe if metadata is missing.
causing 500. Added null check with fallback to invoice.lines.data[0].subscription
and skip if still missing.
(was string, broke TS build).
subscription object as safety net if checkout.session.completed is missed.