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/content-types/event-request/schema.json b/cms/src/api/event-request/content-types/event-request/schema.json index 2ed9e75..2e64561 100644 --- a/cms/src/api/event-request/content-types/event-request/schema.json +++ b/cms/src/api/event-request/content-types/event-request/schema.json @@ -30,6 +30,10 @@ "eventType": { "type": "string" }, + "room": { + "type": "enumeration", + "enum": ["events-hall", "workshop-area", "electronics-area", "studio", "full-space"] + }, "eventName": { "type": "string" }, 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/controllers/form.ts b/cms/src/api/event-request/controllers/form.ts index 3cb9c50..1f30c5d 100644 --- a/cms/src/api/event-request/controllers/form.ts +++ b/cms/src/api/event-request/controllers/form.ts @@ -5,6 +5,7 @@ const formToJsonMap = { "phone": "initiatorPhoneNumber", "company-name": "organization", "event-type": "eventType", + "room": "room", "event-name": "eventName", "event-theme": "eventTheme", "event-purpose": "eventPurpose", @@ -16,6 +17,16 @@ const formToJsonMap = { "expected-guests": "expectedGuests", }; +const ROOM_KEYS = ['events-hall', 'workshop-area', 'electronics-area', 'studio', 'full-space'] as const; + +type RoomKey = (typeof ROOM_KEYS)[number]; + +function parseRoom(value: unknown): RoomKey | undefined { + return typeof value === 'string' && (ROOM_KEYS as readonly string[]).includes(value) + ? (value as RoomKey) + : undefined; +} + interface EventRequestInput { organizingEntity: string; initiatorName: string; @@ -23,6 +34,7 @@ interface EventRequestInput { initiatorPhoneNumber: string; organization: string; eventType: string; + room?: RoomKey; eventName: string; eventTheme: string; eventPurpose: string; @@ -76,6 +88,8 @@ export default { return acc; }, {} as Record); + const room = parseRoom(raw.room); + const eventRequest: EventRequestInput = { organizingEntity: assertString(raw.organizingEntity, 'organizingEntity'), initiatorName: assertString(raw.initiatorName, 'initiatorName'), @@ -83,6 +97,7 @@ export default { initiatorPhoneNumber: assertString(raw.initiatorPhoneNumber, 'initiatorPhoneNumber'), organization: assertString(raw.organization, 'organization'), eventType: assertString(raw.eventType, 'eventType'), + ...(room !== undefined ? { room } : {}), eventName: assertString(raw.eventName, 'eventName'), eventTheme: assertString(raw.eventTheme, 'eventTheme'), eventPurpose: assertString(raw.eventPurpose, 'eventPurpose'), @@ -125,6 +140,7 @@ export default {

Initiator phone number: ${eventRequest.initiatorPhoneNumber}

Organization: ${eventRequest.organization}

Event type: ${eventRequest.eventType}

+

Room: ${eventRequest.room}

Event name: ${eventRequest.eventName}

Event theme: ${eventRequest.eventTheme}

Event purpose: ${eventRequest.eventPurpose}

@@ -136,55 +152,41 @@ export default {

Expected guests: ${eventRequest.expectedGuests}

`; - await strapi.plugins['email'].services.email.send({ - to: eventRequest.initiatorEmail, - from: 'hello@42.mk', - replyTo: 'hello@42.mk', - subject: 'Your event request has been received! - 42.mk', - html: `Thank you for submitting your event request. We will get back to you as soon as possible. - Here's a copy of your request: -

- ${requestCopy} - ` - }); - - await strapi.plugins['email'].services.email.send({ - to: 'hello@42.mk', - from: 'hello@42.mk', - replyTo: eventRequest.initiatorEmail, - subject: `New event request from ${eventRequest.initiatorName}`, - html: `A new event request has been submitted. Here's a copy of the request: -

- ${requestCopy} - ` - }); - - ctx.body = res; - } catch (error) { - console.error('Event request submission error:', error); - - // Try to notify admin of the error try { + await strapi.plugins['email'].services.email.send({ + to: eventRequest.initiatorEmail, + from: 'hello@42.mk', + replyTo: 'hello@42.mk', + subject: 'Your event request has been received! - 42.mk', + html: `Thank you for submitting your event request. We will get back to you as soon as possible. + Here's a copy of your request: +

+ ${requestCopy} + ` + }); + await strapi.plugins['email'].services.email.send({ to: 'hello@42.mk', from: 'hello@42.mk', - subject: '[ERROR] Event request submission failed', - html: `

An error occurred while processing an event request submission.

-

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

-

Stack:

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

Request body:

-
${JSON.stringify(body, null, 2)}
+ replyTo: eventRequest.initiatorEmail, + subject: `New event request from ${eventRequest.initiatorName}`, + html: `A new event request has been submitted. Here's a copy of the request: +

+ ${requestCopy} ` }); } catch (emailError) { - console.error('Failed to send error notification email:', emailError); + console.error('Failed to send confirmation emails:', emailError); } + ctx.body = res; + } catch (error) { + console.error('Event request submission error:', error); + ctx.status = 500; ctx.body = { error: { - message: 'Failed to submit event request. The administrator has been notified.', + message: 'Failed to submit event request.', } }; } diff --git a/cms/src/api/event-request/routes/custom-event-request.ts b/cms/src/api/event-request/routes/custom-event-request.ts deleted file mode 100644 index 3e4b185..0000000 --- a/cms/src/api/event-request/routes/custom-event-request.ts +++ /dev/null @@ -1,13 +0,0 @@ -export default { - routes: [ - { - method: 'POST', - path: '/event-requests/:id/approve', - handler: 'event-request.approve', - config: { - policies: [], - middlewares: [], - }, - }, - ], -}; \ No newline at end of file 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..6a01c3b 100644 --- a/cms/src/extensions/users-permissions/controllers/user.ts +++ b/cms/src/extensions/users-permissions/controllers/user.ts @@ -1,3 +1,7 @@ +import { errors } from '@strapi/utils'; + +const { ValidationError } = errors; + interface UpdateProfileBody { username?: string; firstName?: string; @@ -11,7 +15,14 @@ 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) { if (typeof value !== 'string' || value.length === 0) { @@ -41,6 +52,143 @@ 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(); + + ctx.body = { + id: user.id, + documentId: user.documentId, + username: user.username, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + provider: user.provider, + confirmed: user.confirmed, + blocked: user.blocked, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + profilePicture: user.profilePicture, + userType: user.userType, + memberships: (user.memberships ?? []).map((m: any) => ({ + documentId: m.documentId, + tier: m.tier, + status: m.status, + startDate: m.startDate, + endDate: m.endDate, + })), + }; + }, + + 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 +247,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); @@ -113,7 +264,17 @@ export default { if (!ctx.state.user) { return ctx.unauthorized('You must be logged in'); } - const token = ctx.request.body.token || ctx.request.body.fcmToken; + const token = ctx.request.body.token ?? ctx.request.body.fcmToken; + + if (token === null || token === '') { + await strapi.entityService.update( + 'plugin::users-permissions.user', + ctx.state.user.id, + { data: { fcmToken: null } } + ); + ctx.body = { ok: true }; + return; + } if (!token) { strapi.log.warn(`No FCM token provided. Body: ${JSON.stringify(ctx.request.body)}`); 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..d8fc193 100644 --- a/cms/types/generated/contentTypes.d.ts +++ b/cms/types/generated/contentTypes.d.ts @@ -446,6 +446,7 @@ 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; @@ -468,6 +469,17 @@ export interface ApiEventRequestEventRequest organizingEntity: Schema.Attribute.String; physicalPresence: Schema.Attribute.Boolean; publishedAt: Schema.Attribute.DateTime; + room: Schema.Attribute.Enumeration< + [ + 'events-hall', + 'workshop-area', + 'electronics-area', + 'studio', + '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 +672,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', 'cancel_pending', '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 +776,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 +1333,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 +1350,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 +1364,8 @@ export interface PluginUsersPermissionsUser Schema.Attribute.SetMinMaxLength<{ minLength: 3; }>; + userType: Schema.Attribute.Enumeration<['user', 'volunteer', 'member']> & + Schema.Attribute.DefaultTo<'user'>; }; } @@ -1282,7 +1384,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;