diff --git a/cms/.env.example b/cms/.env.example index 529ba71..370e7e7 100644 --- a/cms/.env.example +++ b/cms/.env.example @@ -8,3 +8,13 @@ JWT_SECRET=tobemodified PROJECTS_TOKEN=tobemodified PROJECTS_ORG=base42 PROJECT_SYNC_SECRET=tobemodified + +# Stripe +STRIPE_WEBHOOK_SECRET=tobemodified +STRIPE_MONTHLY_PRICE_ID=tobemodified +STRIPE_YEARLY_PRICE_ID=tobemodified +STRIPE_SUCCESS_URL=https://42.mk/membership/success +STRIPE_CANCEL_URL=https://42.mk/membership/cancel +STRIPE_RETURN_URL=https://42.mk/membership/manage +STRIPE_SECRET_KEY=tobemodified +STRIPE_PRICE_ID=tobemodified \ No newline at end of file diff --git a/cms/config/middlewares.ts b/cms/config/middlewares.ts index 3ab20d9..b19dca9 100644 --- a/cms/config/middlewares.ts +++ b/cms/config/middlewares.ts @@ -5,6 +5,7 @@ export default [ 'strapi::poweredBy', 'strapi::logger', 'strapi::query', + { name: 'global::stripe-webhook', config: {} }, 'strapi::body', 'strapi::session', 'strapi::favicon', diff --git a/cms/src/api/event-request/controllers/event-request.ts b/cms/src/api/event-request/controllers/event-request.ts index 4a68bed..1dd3f47 100644 --- a/cms/src/api/event-request/controllers/event-request.ts +++ b/cms/src/api/event-request/controllers/event-request.ts @@ -4,65 +4,4 @@ import { factories } from '@strapi/strapi' -export default factories.createCoreController('api::event-request.event-request', ({ strapi }) => ({ - async approve(ctx) { - const user = ctx.state?.user; - if (!user) { - return ctx.unauthorized(); - } - - const { id } = ctx.params; - - const request = (await strapi.documents('api::event-request.event-request').findOne({ - documentId: id, - populate: { event: true } as never, - })) as { - documentId: string; - status?: string; - initiatorEmail?: string; - eventName?: string; - event?: { documentId: string } | null; - } | null; - - if (!request) { - return ctx.notFound('Event request not found'); - } - - if (!request.event) { - return ctx.badRequest('No linked draft event found'); - } - - if (request.status === 'approved') { - return ctx.badRequest('Event request already approved'); - } - - try { - await strapi.documents('api::event.event').publish({ - documentId: request.event.documentId, - }); - - await strapi.documents('api::event-request.event-request').update({ - documentId: id, - data: { status: 'approved' } as Record, - }); - - await strapi.plugins['email'].services.email.send({ - to: request.initiatorEmail, - from: 'hello@42.mk', - replyTo: 'hello@42.mk', - subject: 'Your event has been approved! - 42.mk', - html: ` -

Great news! Your event request "${request.eventName}" has been approved and is now published.

-

You can view it on our platform.

-

Best regards,
42.mk Team

- `, - }); - - ctx.body = { message: 'Event request approved' }; - } catch (error) { - strapi.log.error('Error approving event request:', error); - ctx.status = 500; - ctx.body = { error: { message: 'Failed to approve event request' } }; - } - }, -})); +export default factories.createCoreController('api::event-request.event-request'); diff --git a/cms/src/api/event-request/routes/event-request.ts b/cms/src/api/event-request/routes/event-request.ts index d3f6684..487cfcf 100644 --- a/cms/src/api/event-request/routes/event-request.ts +++ b/cms/src/api/event-request/routes/event-request.ts @@ -1,3 +1,5 @@ import { factories } from '@strapi/strapi'; -export default factories.createCoreRouter('api::event-request.event-request'); \ No newline at end of file +export default { + routes: [], +}; diff --git a/cms/src/api/membership/content-types/membership/schema.json b/cms/src/api/membership/content-types/membership/schema.json new file mode 100644 index 0000000..44ca821 --- /dev/null +++ b/cms/src/api/membership/content-types/membership/schema.json @@ -0,0 +1,42 @@ +{ + "kind": "collectionType", + "collectionName": "memberships", + "info": { + "singularName": "membership", + "pluralName": "memberships", + "displayName": "Membership", + "description": "Tracks user membership subscriptions" + }, + "options": { + "draftAndPublish": false + }, + "pluginOptions": {}, + "attributes": { + "user": { + "type": "relation", + "relation": "manyToOne", + "target": "plugin::users-permissions.user", + "inversedBy": "memberships" + }, + "tier": { + "type": "enumeration", + "enum": ["monthly", "yearly"], + "required": true + }, + "status": { + "type": "enumeration", + "enum": ["active", "inactive", "cancelled", "cancel_pending", "pending"], + "default": "pending" + }, + "startDate": { + "type": "datetime" + }, + "endDate": { + "type": "datetime" + }, + "stripeSubscriptionId": { + "type": "string", + "private": true + } + } +} diff --git a/cms/src/api/membership/controllers/membership.ts b/cms/src/api/membership/controllers/membership.ts new file mode 100644 index 0000000..ac7e9e6 --- /dev/null +++ b/cms/src/api/membership/controllers/membership.ts @@ -0,0 +1,438 @@ +import { factories } from '@strapi/strapi'; + +const UID = 'api::membership.membership'; +let stripeInstance: any = null; + +const USER_UID = 'plugin::users-permissions.user'; + +function getStripe() { + if (!stripeInstance) { + const Stripe = require('stripe'); + stripeInstance = new Stripe(process.env.STRIPE_SECRET_KEY); + } + return stripeInstance; +} + +function getPriceIdForTier(tier: string): string | null { + if (tier === 'yearly') return process.env.STRIPE_YEARLY_PRICE_ID || null; + return process.env.STRIPE_MONTHLY_PRICE_ID || process.env.STRIPE_PRICE_ID || null; +} + +function getTierForPriceId(priceId: string): 'monthly' | 'yearly' { + const yearlyId = process.env.STRIPE_YEARLY_PRICE_ID; + if (yearlyId && priceId === yearlyId) return 'yearly'; + return 'monthly'; +} + +function normalizeTier(tier: string | null | undefined, priceId?: string | null): 'monthly' | 'yearly' { + if (tier === 'monthly' || tier === 'yearly') return tier; + if (priceId) return getTierForPriceId(priceId); + return 'monthly'; +} + +async function getOrCreateStripeCustomer(ctx, stripe) { + const documentId = ctx.state.user.documentId; + + const user = await strapi.documents(USER_UID).findOne({ documentId }); + + if (user.stripeCustomerId) { + return user.stripeCustomerId; + } + + const customer = await stripe.customers.create({ + email: user.email, + name: `${user.firstName || ''} ${user.lastName || ''}`.trim() || user.username, + metadata: { strapiDocumentId: documentId }, + }); + + await strapi.documents(USER_UID).update({ + documentId, + data: { stripeCustomerId: customer.id }, + }); + + return customer.id; +} + +export default factories.createCoreController(UID, ({ strapi }) => ({ + async me(ctx) { + if (!ctx.state.user) return ctx.unauthorized(); + + const memberships = await strapi.documents(UID).findMany({ + filters: { user: { documentId: ctx.state.user.documentId } }, + sort: { startDate: 'desc' }, + populate: [], + }); + + const sanitizedMemberships = (await this.sanitizeOutput(memberships, ctx)) as any[]; + const active = sanitizedMemberships.find((m: any) => m.status === 'active'); + + ctx.body = { + memberships: sanitizedMemberships, + active: active || null, + }; + }, + + async createCheckoutSession(ctx) { + if (!ctx.state.user) return ctx.unauthorized(); + + try { + const stripe = getStripe(); + const rawTier = (ctx.request.body as any)?.tier; + if (rawTier !== undefined && rawTier !== null && rawTier !== 'monthly' && rawTier !== 'yearly') { + return ctx.badRequest('Invalid tier; must be monthly or yearly'); + } + const tier = rawTier || 'monthly'; + const priceId = getPriceIdForTier(tier); + + if (!priceId) { + return ctx.badRequest(`STRIPE_${tier.toUpperCase()}_PRICE_ID not configured`); + } + + const existing = await strapi.db.query(UID).findMany({ + where: { user: ctx.state.user.id, status: 'active' }, + }); + + if (existing.length > 0) { + return ctx.badRequest('Active membership already exists. Use Manage Subscription to make changes.'); + } + + const customerId = await getOrCreateStripeCustomer(ctx, stripe); + + const session = await stripe.checkout.sessions.create({ + customer: customerId, + mode: 'subscription', + line_items: [{ price: priceId, quantity: 1 }], + success_url: process.env.STRIPE_SUCCESS_URL || 'https://42.mk/membership/success', + cancel_url: process.env.STRIPE_CANCEL_URL || 'https://42.mk/membership/cancel', + metadata: { tier }, + subscription_data: { + metadata: { + strapiDocumentId: ctx.state.user.documentId, + tier, + }, + }, + }); + + ctx.body = { url: session.url, sessionId: session.id }; + } catch (error) { + strapi.log.error('Failed to create checkout session:', error); + return ctx.internalServerError('Failed to create checkout session'); + } + }, + + async createPortalSession(ctx) { + if (!ctx.state.user) return ctx.unauthorized(); + + try { + const stripe = getStripe(); + const user = await strapi.documents(USER_UID).findOne({ + documentId: ctx.state.user.documentId, + }); + + if (!user.stripeCustomerId) { + return ctx.badRequest('No Stripe customer found'); + } + + const session = await stripe.billingPortal.sessions.create({ + customer: user.stripeCustomerId, + return_url: process.env.STRIPE_RETURN_URL || 'https://42.mk/membership/manage', + }); + + ctx.body = { url: session.url }; + } catch (error) { + strapi.log.error('Failed to create portal session:', error); + return ctx.internalServerError('Failed to create portal session'); + } + }, + + async webhook(ctx) { + const sig = ctx.request.headers['stripe-signature'] as string; + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + + if (!sig || !webhookSecret) { + ctx.status = 400; + ctx.body = { error: 'Missing signature or webhook secret' }; + return; + } + + const rawBody = ctx.state.rawBody as Buffer | undefined; + if (!rawBody) { + strapi.log.error('Missing raw body in ctx.state.rawBody'); + ctx.status = 400; + ctx.body = { error: 'Missing raw body' }; + return; + } + + try { + const stripe = getStripe(); + const event = stripe.webhooks.constructEvent(rawBody, sig, webhookSecret); + const eventId = event.id; + + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object; + const customerId = session.customer; + const subscriptionId = session.subscription; + + // For one-time payments, still require paid status + if (session.mode !== 'subscription' && session.payment_status !== 'paid') { + strapi.log.info(`[${eventId}] One-time payment not yet received: ${session.payment_status}`); + break; + } + + const existing = await strapi.db.query(UID).findMany({ + where: { stripeSubscriptionId: subscriptionId }, + }); + + if (existing.length > 0) { + strapi.log.info(`[${eventId}] Membership already exists for subscription ${subscriptionId}`); + break; + } + + let strapiDocumentId = session.metadata?.strapiDocumentId || session.subscription_details?.metadata?.strapiDocumentId; + let customerDocId: string | null = null; + + if (!strapiDocumentId) { + const customer = await stripe.customers.retrieve(customerId); + customerDocId = (customer as any).metadata?.strapiDocumentId; + if (customerDocId) { + strapi.log.warn(`[${eventId}] No documentId in session metadata, falling back to customer metadata`); + strapiDocumentId = customerDocId; + } + } + + if (!strapiDocumentId) { + strapi.log.error(`[${eventId}] No strapiDocumentId in session or customer metadata: ${customerId}`); + throw new Error(`No strapiDocumentId found for customer ${customerId}`); + } + + let tier = session.metadata?.tier || session.subscription_details?.metadata?.tier; + let fallbackPriceId: string | undefined; + if (!tier && subscriptionId) { + const sub = await stripe.subscriptions.retrieve(subscriptionId); + tier = sub.metadata?.tier; + fallbackPriceId = sub.items?.data?.[0]?.price?.id; + } + tier = normalizeTier(tier, fallbackPriceId); + const isPaid = session.payment_status === 'paid'; + + await strapi.documents(UID).create({ + data: { + user: strapiDocumentId, + tier, + status: isPaid ? 'active' : 'pending', + startDate: new Date(), + stripeSubscriptionId: subscriptionId, + }, + }); + + await strapi.documents(USER_UID).update({ + documentId: strapiDocumentId, + data: { userType: 'member' }, + }); + + strapi.log.info(`[${eventId}] Membership created for user ${strapiDocumentId}, tier: ${tier}, status: ${isPaid ? 'active' : 'pending'}`); + break; + } + + case 'customer.subscription.created': { + const createdSub = event.data.object; + const createdSubId = createdSub.id; + + const existingMembership = await strapi.db.query(UID).findMany({ + where: { stripeSubscriptionId: createdSubId }, + }); + + if (existingMembership.length > 0) { + strapi.log.info(`[${eventId}] Membership already exists for subscription ${createdSubId}`); + break; + } + + const customerId = createdSub.customer; + const customer = await stripe.customers.retrieve(customerId); + const strapiDocumentId = (customer as any).metadata?.strapiDocumentId; + + if (!strapiDocumentId) { + strapi.log.warn(`[${eventId}] No strapiDocumentId for customer ${customerId}, membership not created`); + break; + } + + const priceId = createdSub.items?.data?.[0]?.price?.id; + const tier = normalizeTier(createdSub.metadata?.tier, priceId); + const currentPeriodEnd = new Date(createdSub.current_period_end * 1000); + + await strapi.documents(UID).create({ + data: { + user: strapiDocumentId, + tier, + status: 'active', + startDate: new Date(), + endDate: currentPeriodEnd, + stripeSubscriptionId: createdSubId, + }, + }); + + await strapi.documents(USER_UID).update({ + documentId: strapiDocumentId, + data: { userType: 'member' }, + }); + + strapi.log.info(`[${eventId}] Membership created from subscription for user ${strapiDocumentId}, tier: ${tier}`); + break; + } + + case 'customer.subscription.deleted': { + const subscription = event.data.object; + const subscriptionId = subscription.id; + + strapi.log.info(`[${eventId}] Subscription ${subscriptionId} deleted`); + + const memberships = await strapi.db.query(UID).findMany({ + where: { stripeSubscriptionId: subscriptionId }, + }); + + if (memberships.length > 0) { + const membership = memberships[0]; + + await strapi.db.query(UID).update({ + where: { id: membership.id }, + data: { status: 'cancelled', endDate: new Date() }, + }); + + const user = await strapi.db.query(USER_UID).findOne({ + where: { id: membership.user }, + }); + + if (user?.document_id) { + await strapi.documents(USER_UID).update({ + documentId: user.document_id, + data: { userType: 'user' }, + }); + strapi.log.info(`[${eventId}] User ${user.document_id} reverted to free`); + } + } + break; + } + + case 'customer.subscription.updated': { + const subscription = event.data.object; + const subscriptionId = subscription.id; + + strapi.log.info(`[${eventId}] Subscription ${subscriptionId} updated`); + + const memberships = await strapi.db.query(UID).findMany({ + where: { stripeSubscriptionId: subscriptionId }, + }); + + if (memberships.length > 0) { + const membership = memberships[0]; + let status = membership.status; + + if (subscription.cancel_at_period_end) { + status = 'cancel_pending'; + } else if (subscription.status === 'active') { + status = 'active'; + } else if (subscription.status === 'canceled' || subscription.status === 'unpaid') { + status = 'cancelled'; + } + + const newPriceId = subscription.items?.data?.[0]?.price?.id; + const tier = newPriceId ? normalizeTier(undefined, newPriceId) : normalizeTier(membership.tier); + + await strapi.db.query(UID).update({ + where: { id: membership.id }, + data: { status, tier }, + }); + } + break; + } + + case 'invoice.payment_succeeded': { + const invoice = event.data.object; + const subscriptionId = invoice.subscription || invoice.lines?.data?.[0]?.subscription; + + if (!subscriptionId) { + strapi.log.warn(`[${eventId}] Invoice ${invoice.id} has no subscription ID, skipping`); + break; + } + + const memberships = await strapi.db.query(UID).findMany({ + where: { stripeSubscriptionId: subscriptionId }, + }); + + if (memberships.length > 0) { + const membership = memberships[0]; + const subscription = await stripe.subscriptions.retrieve(subscriptionId); + const currentPeriodEnd = new Date(subscription.current_period_end * 1000); + + await strapi.db.query(UID).update({ + where: { id: membership.id }, + data: { status: 'active', endDate: currentPeriodEnd }, + }); + } else { + // Async payment: membership may not exist yet, create it + strapi.log.warn(`[${eventId}] Membership not found for subscription ${subscriptionId}, creating from invoice`); + + const subscription = await stripe.subscriptions.retrieve(subscriptionId); + const customerId = subscription.customer; + const customer = await stripe.customers.retrieve(customerId); + const strapiDocumentId = (customer as any).metadata?.strapiDocumentId; + + if (!strapiDocumentId) { + throw new Error(`No strapiDocumentId found for customer ${customerId}`); + } + + const priceId = subscription.items?.data?.[0]?.price?.id; + const tier = normalizeTier(undefined, priceId); + const currentPeriodEnd = new Date(subscription.current_period_end * 1000); + + await strapi.documents(UID).create({ + data: { + user: strapiDocumentId, + tier, + status: 'active', + startDate: new Date(), + endDate: currentPeriodEnd, + stripeSubscriptionId: subscriptionId, + }, + }); + + await strapi.documents(USER_UID).update({ + documentId: strapiDocumentId, + data: { userType: 'member' }, + }); + + strapi.log.info(`[${eventId}] Membership created from invoice for user ${strapiDocumentId}`); + } + break; + } + + case 'invoice.payment_failed': { + const failedInvoice = event.data.object; + const failedSubId = failedInvoice.subscription; + + const failedMemberships = await strapi.db.query(UID).findMany({ + where: { stripeSubscriptionId: failedSubId }, + }); + + if (failedMemberships.length > 0) { + await strapi.db.query(UID).update({ + where: { id: failedMemberships[0].id }, + data: { status: 'pending' }, + }); + } + break; + } + + default: + strapi.log.info(`[${eventId}] Unhandled event type: ${event.type}`); + } + + ctx.body = { received: true }; + } catch (error) { + strapi.log.error('Stripe webhook error:', error); + ctx.status = 500; + ctx.body = { error: 'Webhook processing failed', received: false }; + } + }, +})); diff --git a/cms/src/api/membership/routes/membership.ts b/cms/src/api/membership/routes/membership.ts new file mode 100644 index 0000000..be18529 --- /dev/null +++ b/cms/src/api/membership/routes/membership.ts @@ -0,0 +1,37 @@ +export default { + routes: [ + { + method: 'GET', + path: '/memberships/me', + handler: 'membership.me', + config: { + prefix: '', + }, + }, + { + method: 'POST', + path: '/memberships/create-checkout', + handler: 'membership.createCheckoutSession', + config: { + prefix: '', + }, + }, + { + method: 'POST', + path: '/memberships/portal', + handler: 'membership.createPortalSession', + config: { + prefix: '', + }, + }, + { + method: 'POST', + path: '/memberships/webhook', + handler: 'membership.webhook', + config: { + prefix: '', + auth: false, + }, + }, + ], +}; diff --git a/cms/src/api/membership/services/membership.ts b/cms/src/api/membership/services/membership.ts new file mode 100644 index 0000000..96b82e6 --- /dev/null +++ b/cms/src/api/membership/services/membership.ts @@ -0,0 +1,3 @@ +import { factories } from '@strapi/strapi'; + +export default factories.createCoreService('api::membership.membership'); diff --git a/cms/src/extensions/users-permissions/controllers/user.ts b/cms/src/extensions/users-permissions/controllers/user.ts index cb804c1..4570c0a 100644 --- a/cms/src/extensions/users-permissions/controllers/user.ts +++ b/cms/src/extensions/users-permissions/controllers/user.ts @@ -11,9 +11,16 @@ const USERNAME_REGEX = /^[a-z0-9_]{3,20}$/; const NAME_REGEX = /^[a-zA-Z\s\-'.]+$/; const SANITIZE_REGEX = /[<>\"'&]/g; -class ValidationError extends Error {} +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} -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`); } else if (value.length < 2) { @@ -41,6 +48,126 @@ function validateProfilePictureId(id: any) { } export default { + async me(ctx) { + if (!ctx.state.user) return ctx.unauthorized(); + + const user = await strapi.documents('plugin::users-permissions.user').findOne({ + documentId: ctx.state.user.documentId, + populate: ['role', 'profilePicture', 'memberships'], + }); + + if (!user) return ctx.notFound(); + + const userSchema = strapi.contentType('plugin::users-permissions.user'); + const sanitizedUser = await strapi.contentAPI.sanitize.output(user, userSchema, { + auth: ctx.state.auth ?? {}, + }); + ctx.body = sanitizedUser; + }, + + async volunteerApply(ctx) { + if (!ctx.state.user) return ctx.unauthorized(); + + const user = ctx.state.user; + const body = ctx.request.body; + + if (!body || typeof body !== 'object') { + ctx.status = 400; + ctx.body = { error: { message: 'Invalid request body' } }; + return; + } + + const { skills, message } = body; + + if (typeof skills !== 'string' || !skills.trim()) { + ctx.status = 400; + ctx.body = { error: { message: 'Skills must be a non-empty string' } }; + return; + } + if (typeof message !== 'string' || !message.trim()) { + ctx.status = 400; + ctx.body = { error: { message: 'Message must be a non-empty string' } }; + return; + } + + const MAX_LEN = 5000; + if (skills.length > MAX_LEN || message.length > MAX_LEN) { + ctx.status = 400; + ctx.body = { error: { message: 'Skills or message exceed maximum length' } }; + return; + } + + const applicantName = ( + [user.firstName, user.lastName].filter(Boolean).join(' ').trim() + || user.username + ); + const applicantEmail = user.email; + + const applicationHtml = ` +

Name: ${escapeHtml(applicantName)}

+

Email: ${escapeHtml(applicantEmail)}

+

Skills & Interests: ${escapeHtml(skills)}

+

Why they want to volunteer:

+

${escapeHtml(message)}

+ `; + + try { + // Confirmation to the applicant + await strapi.plugins['email'].services.email.send({ + to: applicantEmail, + from: 'hello@42.mk', + replyTo: 'hello@42.mk', + subject: 'Your volunteer application has been received! - 42.mk', + html: `Thank you for applying to volunteer at Base42! We'll review your application and get back to you soon. +

+ Here's a copy of your application: +

+ ${applicationHtml} + `, + }); + + // Notification to admin + await strapi.plugins['email'].services.email.send({ + to: 'hello@42.mk', + from: 'hello@42.mk', + replyTo: applicantEmail, + subject: `New volunteer application from ${applicantName}`, + html: `A new volunteer application has been submitted. Here are the details: +

+ ${applicationHtml} + `, + }); + + ctx.body = { ok: true, message: 'Volunteer application submitted successfully' }; + } catch (error) { + strapi.log.error(`Volunteer application email failed: ${error}`); + + try { + await strapi.plugins['email'].services.email.send({ + to: 'hello@42.mk', + from: 'hello@42.mk', + subject: '[ERROR] Volunteer application email failed', + html: `

An error occurred while sending volunteer application emails.

+

Error: ${error.message || String(error)}

+

Stack:

+
${error.stack || 'No stack trace available'}
+

Applicant name: ${escapeHtml(applicantName)}

+

Applicant email: ${escapeHtml(applicantEmail)}

+ `, + }); + } catch (emailError) { + strapi.log.error('Failed to send error notification email:', emailError); + } + + ctx.status = 500; + ctx.body = { + error: { + message: 'Failed to submit volunteer application. Please try again later.', + } + }; + } + }, + async updateProfile(ctx) { if (!ctx.state.user) return ctx.unauthorized(); @@ -99,8 +226,11 @@ export default { data: safeData, }); - const { password, resetPasswordToken, confirmationToken, fcmToken, ...safeUser } = updated; - ctx.body = safeUser; + const userSchema = strapi.contentType('plugin::users-permissions.user'); + const sanitizedUser = await strapi.contentAPI.sanitize.output(updated, userSchema, { + auth: ctx.state.auth ?? {}, + }); + ctx.body = sanitizedUser; } catch (error) { if (error instanceof ValidationError) { return ctx.badRequest(error.message); diff --git a/cms/src/extensions/users-permissions/routes/custom-routes.ts b/cms/src/extensions/users-permissions/routes/custom-routes.ts index 0e99165..53bd058 100644 --- a/cms/src/extensions/users-permissions/routes/custom-routes.ts +++ b/cms/src/extensions/users-permissions/routes/custom-routes.ts @@ -17,5 +17,13 @@ export default { prefix: '', }, }, + { + method: 'POST', + path: '/volunteer/apply', + handler: 'user.volunteerApply', + config: { + prefix: '', + }, + }, ], } diff --git a/cms/src/extensions/users-permissions/strapi-server.ts b/cms/src/extensions/users-permissions/strapi-server.ts index a6aa100..8f439db 100644 --- a/cms/src/extensions/users-permissions/strapi-server.ts +++ b/cms/src/extensions/users-permissions/strapi-server.ts @@ -30,8 +30,28 @@ module.exports = (plugin) => { mappedBy: 'user', }; + userAttributes.stripeCustomerId = { + type: 'string', + private: true, + }; + + userAttributes.userType = { + type: 'enumeration', + enum: ['user', 'volunteer', 'member'], + default: 'user', + }; + + userAttributes.memberships = { + type: 'relation', + relation: 'oneToMany', + target: 'api::membership.membership', + mappedBy: 'user', + }; + plugin.controllers.user.updateProfile = userController.updateProfile; plugin.controllers.user.saveFcmToken = userController.saveFcmToken; + plugin.controllers.user.me = userController.me; + plugin.controllers.user.volunteerApply = userController.volunteerApply; plugin.routes['content-api'].routes.push(...customRoutes.routes); diff --git a/cms/src/middlewares/stripe-webhook.ts b/cms/src/middlewares/stripe-webhook.ts new file mode 100644 index 0000000..e66ddfe --- /dev/null +++ b/cms/src/middlewares/stripe-webhook.ts @@ -0,0 +1,25 @@ +import { PassThrough } from 'stream'; + +export default (config, { strapi }) => { + return async (ctx, next) => { + if (ctx.path === '/api/memberships/webhook' && ctx.method === 'POST') { + const chunks = []; + for await (const chunk of ctx.req) { + chunks.push(chunk); + } + const rawBody = Buffer.concat(chunks); + ctx.state.rawBody = rawBody; + + const stream: any = new PassThrough(); + stream.push(rawBody); + stream.push(null); + stream.headers = ctx.req.headers; + stream.httpVersion = ctx.req.httpVersion; + stream.method = ctx.req.method; + stream.url = ctx.req.url; + + ctx.req = stream; + } + await next(); + }; +}; diff --git a/cms/types/generated/contentTypes.d.ts b/cms/types/generated/contentTypes.d.ts index 718bee2..db6dc65 100644 --- a/cms/types/generated/contentTypes.d.ts +++ b/cms/types/generated/contentTypes.d.ts @@ -446,28 +446,26 @@ export interface ApiEventRequestEventRequest createdAt: Schema.Attribute.DateTime; createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & Schema.Attribute.Private; + event: Schema.Attribute.Relation<'oneToOne', 'api::event.event'>; eventAgenda: Schema.Attribute.RichText; eventDate: Schema.Attribute.Date; eventEnd: Schema.Attribute.Time; - eventName: Schema.Attribute.String; - eventPurpose: Schema.Attribute.String; eventStart: Schema.Attribute.Time; - eventTheme: Schema.Attribute.String; - eventType: Schema.Attribute.String; expectedGuests: Schema.Attribute.Integer; initiatorEmail: Schema.Attribute.String; - initiatorName: Schema.Attribute.String; - initiatorPhoneNumber: Schema.Attribute.String; locale: Schema.Attribute.String & Schema.Attribute.Private; localizations: Schema.Attribute.Relation< 'oneToMany', 'api::event-request.event-request' > & Schema.Attribute.Private; - organization: Schema.Attribute.String; organizingEntity: Schema.Attribute.String; - physicalPresence: Schema.Attribute.Boolean; publishedAt: Schema.Attribute.DateTime; + space: Schema.Attribute.Enumeration< + ['events-hall', 'workshop-area', 'electronics-area', 'full-space'] + >; + status: Schema.Attribute.Enumeration<['pending', 'approved']> & + Schema.Attribute.DefaultTo<'pending'>; updatedAt: Schema.Attribute.DateTime; updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & Schema.Attribute.Private; @@ -660,6 +658,47 @@ export interface ApiHomeHome extends Struct.SingleTypeSchema { }; } +export interface ApiMembershipMembership extends Struct.CollectionTypeSchema { + collectionName: 'memberships'; + info: { + description: 'Tracks user membership subscriptions'; + displayName: 'Membership'; + pluralName: 'memberships'; + singularName: 'membership'; + }; + options: { + draftAndPublish: false; + }; + attributes: { + createdAt: Schema.Attribute.DateTime; + createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & + Schema.Attribute.Private; + endDate: Schema.Attribute.DateTime; + locale: Schema.Attribute.String & Schema.Attribute.Private; + localizations: Schema.Attribute.Relation< + 'oneToMany', + 'api::membership.membership' + > & + Schema.Attribute.Private; + publishedAt: Schema.Attribute.DateTime; + startDate: Schema.Attribute.DateTime; + status: Schema.Attribute.Enumeration< + ['active', 'inactive', 'cancelled', 'pending'] + > & + Schema.Attribute.DefaultTo<'pending'>; + stripeSubscriptionId: Schema.Attribute.String & Schema.Attribute.Private; + tier: Schema.Attribute.Enumeration<['monthly', 'yearly']> & + Schema.Attribute.Required; + updatedAt: Schema.Attribute.DateTime; + updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & + Schema.Attribute.Private; + user: Schema.Attribute.Relation< + 'manyToOne', + 'plugin::users-permissions.user' + >; + }; +} + export interface ApiPartnerPartner extends Struct.CollectionTypeSchema { collectionName: 'partners'; info: { @@ -723,6 +762,48 @@ export interface ApiPartnerPartner extends Struct.CollectionTypeSchema { }; } +export interface ApiProjectProject extends Struct.CollectionTypeSchema { + collectionName: 'projects'; + info: { + description: 'Cached GitHub repositories for mobile app consumption'; + displayName: 'Project'; + pluralName: 'projects'; + singularName: 'project'; + }; + options: { + draftAndPublish: true; + }; + attributes: { + commit_activity: Schema.Attribute.JSON; + contributors: Schema.Attribute.JSON; + createdAt: Schema.Attribute.DateTime; + createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & + Schema.Attribute.Private; + description: Schema.Attribute.Text; + github_repo_id: Schema.Attribute.String & + Schema.Attribute.Required & + Schema.Attribute.Unique; + help_wanted_count: Schema.Attribute.Integer & Schema.Attribute.DefaultTo<0>; + language: Schema.Attribute.String; + last_synced_at: Schema.Attribute.DateTime; + locale: Schema.Attribute.String & Schema.Attribute.Private; + localizations: Schema.Attribute.Relation< + 'oneToMany', + 'api::project.project' + > & + Schema.Attribute.Private; + name: Schema.Attribute.String & Schema.Attribute.Required; + owner_login: Schema.Attribute.String & Schema.Attribute.Required; + pr_count: Schema.Attribute.Integer & Schema.Attribute.DefaultTo<0>; + publishedAt: Schema.Attribute.DateTime; + pushed_at: Schema.Attribute.DateTime; + stargazers_count: Schema.Attribute.Integer & Schema.Attribute.DefaultTo<0>; + updatedAt: Schema.Attribute.DateTime; + updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & + Schema.Attribute.Private; + }; +} + export interface ApiUserEventUserEvent extends Struct.CollectionTypeSchema { collectionName: 'user_events'; info: { @@ -1238,6 +1319,10 @@ export interface PluginUsersPermissionsUser 'plugin::users-permissions.user' > & Schema.Attribute.Private; + memberships: Schema.Attribute.Relation< + 'oneToMany', + 'api::membership.membership' + >; password: Schema.Attribute.Password & Schema.Attribute.Private & Schema.Attribute.SetMinMaxLength<{ @@ -1251,6 +1336,7 @@ export interface PluginUsersPermissionsUser 'manyToOne', 'plugin::users-permissions.role' >; + stripeCustomerId: Schema.Attribute.String & Schema.Attribute.Private; updatedAt: Schema.Attribute.DateTime; updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & Schema.Attribute.Private; @@ -1264,6 +1350,8 @@ export interface PluginUsersPermissionsUser Schema.Attribute.SetMinMaxLength<{ minLength: 3; }>; + userType: Schema.Attribute.Enumeration<['user', 'volunteer', 'member']> & + Schema.Attribute.DefaultTo<'user'>; }; } @@ -1282,7 +1370,9 @@ declare module '@strapi/strapi' { 'api::event.event': ApiEventEvent; 'api::gallery.gallery': ApiGalleryGallery; 'api::home.home': ApiHomeHome; + 'api::membership.membership': ApiMembershipMembership; 'api::partner.partner': ApiPartnerPartner; + 'api::project.project': ApiProjectProject; 'api::user-event.user-event': ApiUserEventUserEvent; 'plugin::content-releases.release': PluginContentReleasesRelease; 'plugin::content-releases.release-action': PluginContentReleasesReleaseAction;