diff --git a/apps/backend/.env.example b/apps/backend/.env.example index d2d23164f4..41293dae89 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -58,5 +58,9 @@ SEGMENT_KEY=xxxx SENDWITHUS_KEY= SLACK_APP_CLIENT_ID=XXX SLACK_APP_CLIENT_SECRET=XXX -STRIPE_API_KEY= +# Stripe API Keys - Get these from https://dashboard.stripe.com/apikeys +# Use test keys (starting with sk_test_ and pk_test_) for development +STRIPE_SECRET_KEY= +STRIPE_PUBLISHABLE_KEY= UPLOADER_PATH_PREFIX=evo-uploads +STRIPE_WEBHOOK_SECRET= diff --git a/apps/backend/Procfile.dev-stripe b/apps/backend/Procfile.dev-stripe new file mode 100644 index 0000000000..c294f13d4d --- /dev/null +++ b/apps/backend/Procfile.dev-stripe @@ -0,0 +1,4 @@ +web: nodemon --inspect=3002 --ext js,json,graphql app.js --delay=0.5 +worker: DELAY_START=5 nodemon worker.js --delay=0.5 +stripe: stripe listen --forward-to localhost:3001/noo/stripe/webhook + diff --git a/apps/backend/README.md b/apps/backend/README.md index b9e58a7690..e8aeb22946 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -126,6 +126,81 @@ Change your `.env` file to have: PROTOCOL=https ``` +### Stripe Integration (Local Development) + +To test Stripe webhooks locally, you need to install the Stripe CLI and forward webhook events to your local server. + +#### Installing the Stripe CLI + +**macOS (Homebrew):** +```shell +brew install stripe/stripe-cli/stripe +``` + +**Linux:** +```shell +# Download the latest linux tar.gz from https://github.com/stripe/stripe-cli/releases/latest +tar -xvf stripe_X.X.X_linux_x86_64.tar.gz +sudo mv stripe /usr/local/bin +``` + +For other installation methods, see the [Stripe CLI documentation](https://stripe.com/docs/stripe-cli#install). + +#### Setting Up Your Stripe Sandbox Environment + +Before you can test Stripe locally, you need access to a personal sandbox environment: + +1. **Get access to the team Stripe account** - Ask an existing team member with admin permissions on the Stripe account to invite you to the Hylo Stripe team. + +2. **Create your personal sandbox** - Once you have access, log into the [Stripe Dashboard](https://dashboard.stripe.com) and create a sandbox environment by cloning the production configuration: + - Navigate to the sandbox/test environment selector (top-left of dashboard) + - Click "New sandbox" or ask a team member with permissions to create one for you + - Clone from the production environment to get all the existing products, prices, and webhook configurations + +3. **Get your sandbox API keys** - In your sandbox environment: + - Go to Developers → API keys + - Copy your **Publishable key** (`pk_test_...`) and **Secret key** (`sk_test_...`) + +4. **Add the keys to your `.env` file**: +``` +STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here +STRIPE_SECRET_KEY=sk_test_your_secret_key_here +``` + +#### Authenticating with Stripe CLI + +After installing the CLI, authenticate with your Stripe account: +```shell +stripe login +``` + +This will open a browser window to complete the authentication. Make sure you select your personal sandbox environment when prompted. + +#### Listening for Webhook Events + +To forward Stripe webhook events to your local backend server, run: +```shell +stripe listen --forward-to localhost:3001/noo/stripe/webhook +``` + +This will output a webhook signing secret that looks like `whsec_...`. Copy this value and add it to your `.env` file: +``` +STRIPE_WEBHOOK_SECRET=whsec_your_signing_secret_here +``` + +**Note:** The webhook signing secret changes each time you run `stripe listen`, so you'll need to update your `.env` file accordingly when restarting the listener. + +#### Triggering Test Events + +You can trigger test webhook events to verify your integration: +```shell +stripe trigger checkout.session.completed +stripe trigger customer.subscription.created +stripe trigger invoice.paid +``` + +For more information about Stripe webhooks, see the [Stripe Webhooks documentation](https://stripe.com/docs/webhooks). + ### Setting up to handle auth with JWTs and become an OpenID Connect provider - Run `yarn generate-rsa-key-base64` - Copy generated base64 string to .env file: `OIDC_KEYS=base64key` diff --git a/apps/backend/api/controllers/StripeController.js b/apps/backend/api/controllers/StripeController.js new file mode 100644 index 0000000000..0b2614848f --- /dev/null +++ b/apps/backend/api/controllers/StripeController.js @@ -0,0 +1,2063 @@ +/** + * StripeController + * + * Controller for handling Stripe Connect REST endpoints. + * Provides endpoints for: + * - Handling OAuth redirects + * - Processing webhooks + * - Handling checkout success/cancel pages + */ + +const StripeService = require('../services/StripeService') +const Stripe = require('stripe') +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: '2025-10-29.clover' +}) +const { en } = require('../../lib/i18n/en') +const { es } = require('../../lib/i18n/es') +const locales = { en, es } + +/* global bookshelf, StripeAccount, StripeProduct, ContentAccess, GroupMembership, Group, User, Track, Frontend */ + +module.exports = { + + /** + * Handles successful checkout and displays confirmation + * + * Called when a customer completes a purchase and is redirected + * from Stripe Checkout. Retrieves the session to verify payment. + * + * GET /noo/stripe/checkout/success?session_id=xxx&account_id=xxx + */ + checkoutSuccess: async function (req, res) { + try { + const sessionId = req.query.session_id + const accountId = req.query.account_id + + if (!sessionId || !accountId) { + return res.status(400).json({ + error: 'Missing session_id or account_id parameter' + }) + } + + // Retrieve the checkout session to verify payment + const session = await StripeService.getCheckoutSession(accountId, sessionId) + + return res.json({ + success: true, + message: 'Payment successful!', + session: { + id: session.id, + payment_status: session.payment_status, + amount_total: session.amount_total, + currency: session.currency + } + }) + } catch (error) { + console.error('Error in checkoutSuccess:', error) + return res.status(500).json({ + error: 'Failed to process successful checkout', + message: error.message + }) + } + }, + + /** + * Handles checkout cancellation + * + * Called when a customer cancels the checkout process + * + * GET /noo/stripe/checkout/cancel + */ + checkoutCancel: function (req, res) { + // In a real application, you might want to: + // 1. Log the cancellation + // 2. Show a message to the user + // 3. Redirect back to the product page + + return res.json({ + success: false, + message: 'Checkout was cancelled' + }) + }, + + /** + * Webhook endpoint for Stripe events + * + * Stripe sends webhook events for important account and payment updates. + * This endpoint verifies the webhook signature and processes events. + * + * POST /noo/stripe/webhook + */ + webhook: async function (req, res) { + try { + // Get the webhook signature from headers + const signature = req.headers['stripe-signature'] + + if (!signature) { + console.error('Missing Stripe signature header') + return res.status(400).json({ error: 'Missing signature' }) + } + + if (!process.env.STRIPE_WEBHOOK_SECRET) { + console.error('STRIPE_WEBHOOK_SECRET environment variable is not set') + return res.status(500).json({ error: 'Webhook secret not configured' }) + } + + // Verify webhook signature + // req.body should be a Buffer from bodyParser.raw() middleware + let event + try { + event = stripe.webhooks.constructEvent( + req.body, + signature, + process.env.STRIPE_WEBHOOK_SECRET + ) + } catch (err) { + console.error('Webhook signature verification failed:', err.message) + return res.status(400).json({ error: 'Invalid signature' }) + } + + if (process.env.NODE_ENV === 'development') { + console.log(`Processing webhook event: ${event.type}`) + } + + // Handle different event types + // Use module.exports to access handler methods (this context is lost in Sails controllers) + const handlers = module.exports + switch (event.type) { + case 'account.updated': + await handlers.handleAccountUpdated(event) + break + + case 'checkout.session.completed': + await handlers.handleCheckoutSessionCompleted(event) + break + + case 'product.updated': + await handlers.handleProductUpdated(event) + break + + case 'customer.subscription.created': + await handlers.handleSubscriptionCreated(event) + break + + case 'customer.subscription.updated': + await handlers.handleSubscriptionUpdated(event) + break + + case 'customer.subscription.deleted': + await handlers.handleSubscriptionDeleted(event) + break + + case 'invoice.paid': + await handlers.handleInvoicePaid(event) + break + + case 'invoice.payment_failed': + await handlers.handleInvoicePaymentFailed(event) + break + + case 'charge.refunded': + await handlers.handleChargeRefunded(event) + break + + default: + if (process.env.NODE_ENV === 'development') { + console.log(`Unhandled event type: ${event.type}`) + } + } + + // Return a 200 response to acknowledge receipt of the event + return res.json({ received: true }) + } catch (error) { + console.error('Webhook error:', error) + return res.status(400).json({ + error: 'Webhook processing failed', + message: error.message + }) + } + }, + + /** + * Handle account.updated webhook events + * Updates group's Stripe account status when onboarding changes + */ + handleAccountUpdated: async function (event) { + try { + const account = event.data.object + if (process.env.NODE_ENV === 'development') { + console.log(`Account updated: ${account.id}`) + } + + // Extract group ID from account metadata + const groupId = account.metadata?.group_id + if (!groupId) { + if (process.env.NODE_ENV === 'development') { + console.log(`No group_id metadata found for Stripe account: ${account.id}`) + } + return + } + + const group = await Group.find(groupId) + if (!group) { + if (process.env.NODE_ENV === 'development') { + console.log(`No group found with ID: ${groupId}`) + } + return + } + + // Find or create the StripeAccount record for this external account ID + let stripeAccountRecord = await StripeAccount.where({ + stripe_account_external_id: account.id + }).fetch() + + if (!stripeAccountRecord) { + stripeAccountRecord = await StripeAccount.forge({ + stripe_account_external_id: account.id + }).save() + } + + // Update group's Stripe status + // Note: stripe_account_id should be the database ID, not the external account ID + await group.save({ + stripe_account_id: stripeAccountRecord.id, // Store database ID, not external account ID + stripe_charges_enabled: account.charges_enabled, + stripe_payouts_enabled: account.payouts_enabled, + stripe_details_submitted: account.details_submitted + }) + + if (process.env.NODE_ENV === 'development') { + console.log(`Updated Stripe status for group ${group.get('id')}`) + } + } catch (error) { + console.error('Error handling account.updated:', error) + throw error + } + }, + + /** + * Handle checkout.session.completed webhook events + * Grants access to content when checkout completes successfully + */ + handleCheckoutSessionCompleted: async function (event) { + try { + const session = event.data.object + if (process.env.NODE_ENV === 'development') { + console.log(`Checkout session completed: ${session.id}`) + } + + // Verify payment was successful + if (session.payment_status !== 'paid') { + if (process.env.NODE_ENV === 'development') { + console.log(`Checkout session ${session.id} completed but payment status is ${session.payment_status}`) + } + return + } + + // Extract user and product info from session metadata + const userId = session.metadata?.userId + const groupId = session.metadata?.groupId + const offeringId = session.metadata?.offeringId + + if (!userId || !groupId || !offeringId) { + if (process.env.NODE_ENV === 'development') { + console.log(`Missing required metadata in session ${session.id}:`, { + userId: !!userId, + groupId: !!groupId, + offeringId: !!offeringId + }) + } + return + } + + // Find the offering (StripeProduct) by database ID + const offering = await StripeProduct.where({ id: offeringId }).fetch() + if (!offering) { + if (process.env.NODE_ENV === 'development') { + console.log(`No offering found for ID: ${offeringId}`) + } + return + } + + // Verify the offering belongs to the specified group + const offeringGroupId = offering.get('group_id') + if (parseInt(offeringGroupId) !== parseInt(groupId)) { + if (process.env.NODE_ENV === 'development') { + console.log(`Offering ${offeringId} does not belong to group ${groupId}`) + } + return + } + + // Determine if this is a subscription based on session mode + const stripeSubscriptionId = session.subscription || null + + const userIdNum = parseInt(userId, 10) + const grantedByGroupIdNum = parseInt(offeringGroupId, 10) + + // FIRST: Determine which groups need membership from the offering's access_grants + // We need to ensure membership BEFORE assigning roles + const accessGrants = offering.get('access_grants') || {} + const groupsToJoin = new Set() + + // Add the group that owns the product (always grant access to this group) + groupsToJoin.add(grantedByGroupIdNum) + + // Add any groups specified in access_grants.groupIds + if (accessGrants.groupIds && Array.isArray(accessGrants.groupIds)) { + for (const groupId of accessGrants.groupIds) { + const groupIdNum = parseInt(groupId, 10) + if (!isNaN(groupIdNum) && groupIdNum > 0) { + groupsToJoin.add(groupIdNum) + } + } + } + + // If groupRoleIds or commonRoleIds are specified, ensure membership for those groups + if (accessGrants.groupRoleIds || accessGrants.commonRoleIds) { + const groupIdsForRoles = accessGrants.groupIds && Array.isArray(accessGrants.groupIds) && accessGrants.groupIds.length > 0 + ? accessGrants.groupIds.map(id => parseInt(id, 10)).filter(id => !isNaN(id) && id > 0) + : [grantedByGroupIdNum] + for (const groupIdNum of groupIdsForRoles) { + groupsToJoin.add(groupIdNum) + } + } + + // Ensure user is a member of all groups that will receive access BEFORE assigning roles + for (const accessGroupId of groupsToJoin) { + try { + const membership = await GroupMembership.ensureMembership(userIdNum, accessGroupId, { + role: GroupMembership.Role.DEFAULT + }) + + // Record agreement acceptance - user accepted agreements before purchase + if (membership) { + await membership.acceptAgreements() + } + + // Pin the purchased group to the user's global navigation + await GroupMembership.pinGroupToNav(userIdNum, accessGroupId) + + if (process.env.NODE_ENV === 'development') { + console.log(`Ensured group membership for user ${userIdNum} in group ${accessGroupId}`) + } + } catch (error) { + console.error(`Error ensuring membership for user ${userIdNum} in group ${accessGroupId}:`, error) + // Continue processing other groups even if one fails + } + } + + // NOW: Generate content access records and assign roles (membership is already ensured) + const accessRecords = await offering.generateContentAccessRecords({ + userId: userIdNum, + sessionId: session.id, + stripeSubscriptionId, + metadata: { + paymentAmount: session.amount_total, + currency: session.currency, + purchasedAt: new Date().toISOString() + } + }) + + if (process.env.NODE_ENV === 'development') { + console.log(`Created ${accessRecords.length} content access records for user ${userId}`) + } + + // Handle donation transfer if customer added optional donation item + // Check line items for donation to Hylo + let donationAmount = 0 + try { + // Get the group to find the connected account ID first + const group = await Group.find(groupId) + if (group) { + const stripeAccountId = group.get('stripe_account_id') + if (stripeAccountId) { + // Convert database ID to external account ID if needed + const getExternalAccountId = async (accountId) => { + if (accountId && accountId.startsWith('acct_')) { + return accountId + } + const StripeAccount = bookshelf.model('StripeAccount') + const stripeAccount = await StripeAccount.where({ id: accountId }).fetch() + if (!stripeAccount) { + throw new Error('Stripe account record not found') + } + return stripeAccount.get('stripe_account_external_id') + } + + const externalAccountId = await getExternalAccountId(stripeAccountId) + + // Retrieve full session with line items expanded to check for donations + const fullSession = await stripe.checkout.sessions.retrieve(session.id, { + expand: ['line_items'] + }, { + stripeAccount: externalAccountId + }) + + // Look for donation line items in the session + // Track donation details for acknowledgment email + const donationDetails = { + isRecurring: false, + recurringInterval: null, + donationType: 'one-time' + } + + // Sum all donations (one-time and recurring) in case customer added both + if (fullSession.line_items?.data) { + for (const lineItem of fullSession.line_items.data) { + const productName = lineItem.price?.product?.name || lineItem.description || '' + const isDonation = productName.toLowerCase().includes('donation to hylo') + + if (isDonation) { + // Calculate donation amount: unit_amount * quantity + const itemDonationAmount = (lineItem.price.unit_amount || 0) * (lineItem.quantity || 0) + donationAmount += itemDonationAmount + + // Track donation type and recurring details + const isRecurring = lineItem.price?.recurring !== null && lineItem.price?.recurring !== undefined + if (isRecurring) { + donationDetails.isRecurring = true + donationDetails.donationType = 'recurring' + // Map Stripe interval to template format + const interval = lineItem.price.recurring.interval + if (interval === 'month') { + donationDetails.recurringInterval = 'monthly' + } else if (interval === 'year') { + donationDetails.recurringInterval = 'annually' + } else if (interval === 'week') { + donationDetails.recurringInterval = 'weekly' + } else if (interval === 'day') { + donationDetails.recurringInterval = 'daily' + } else { + donationDetails.recurringInterval = interval + } + } + + if (process.env.NODE_ENV === 'development') { + console.log(`Found ${isRecurring ? 'recurring' : 'one-time'} donation: ${itemDonationAmount} ${session.currency || 'usd'}`) + } + } + } + } + + // Transfer donation if present + if (donationAmount > 0) { + const paymentIntentId = session.payment_intent + + if (paymentIntentId) { + await StripeService.transferDonationToPlatform({ + connectedAccountId: externalAccountId, + paymentIntentId, + donationAmount, + currency: session.currency || 'usd' + }) + + if (process.env.NODE_ENV === 'development') { + console.log(`Transferred donation of ${donationAmount} ${session.currency || 'usd'} to platform`) + } + + // Send Donation Acknowledgment email + try { + const user = await User.find(userId) + if (user && user.get('email')) { + const userLocale = user.getLocale() + const donationDate = new Date(session.created * 1000) + const donationDateFormatted = donationDate.toLocaleDateString( + userLocale === 'es' ? 'es-ES' : 'en-US', + { + year: 'numeric', + month: 'long', + day: 'numeric' + } + ) + + const donationAmountFormatted = StripeService.formatPrice(donationAmount, session.currency || 'usd') + + // Get group and offering info for context + const group = await Group.find(groupId) + const offering = await StripeProduct.where({ id: offeringId }).fetch() + let purchaseContext = null + if (offering) { + purchaseContext = offering.get('name') + } + + // Determine next donation date for recurring donations + let nextDonationDate = null + if (donationDetails.isRecurring && donationDetails.recurringInterval) { + const nextDate = new Date(donationDate) + if (donationDetails.recurringInterval === 'monthly') { + nextDate.setMonth(nextDate.getMonth() + 1) + } else if (donationDetails.recurringInterval === 'annually') { + nextDate.setFullYear(nextDate.getFullYear() + 1) + } else if (donationDetails.recurringInterval === 'weekly') { + nextDate.setDate(nextDate.getDate() + 7) + } else if (donationDetails.recurringInterval === 'daily') { + nextDate.setDate(nextDate.getDate() + 1) + } + nextDonationDate = nextDate.toLocaleDateString( + userLocale === 'es' ? 'es-ES' : 'en-US', + { + year: 'numeric', + month: 'long', + day: 'numeric' + } + ) + } + + // Fiscal sponsor info (use env var or default) + const fiscalSponsorName = process.env.FISCAL_SPONSOR_NAME || 'our fiscal sponsor' + const localeObj = locales[userLocale] || locales.en + const taxReceiptInfo = localeObj.donationTaxReceiptInfo() + const impactMessage = donationDetails.isRecurring + ? localeObj.donationRecurringImpactMessage() + : localeObj.donationImpactMessage() + + const emailData = { + user_name: user.get('name'), + donation_amount: donationAmountFormatted, + donation_type: donationDetails.donationType, + donation_date: donationDateFormatted, + is_tax_deductible: true, + fiscal_sponsor_name: fiscalSponsorName, + tax_receipt_info: taxReceiptInfo, + is_recurring: donationDetails.isRecurring, + impact_message: impactMessage + } + + if (donationDetails.isRecurring) { + emailData.next_donation_date = nextDonationDate + emailData.recurring_interval = donationDetails.recurringInterval + emailData.manage_donation_url = `${process.env.FRONTEND_URL || 'https://hylo.com'}/settings/subscriptions` + } + + if (purchaseContext) { + emailData.purchase_context = purchaseContext + } + + if (group) { + emailData.group_name = group.get('name') + } + + Queue.classMethod('Email', 'sendDonationAcknowledgment', { + email: user.get('email'), + data: emailData, + version: 'Redesign 2025', + locale: userLocale + }) + + if (process.env.NODE_ENV === 'development') { + console.log(`Queued Donation Acknowledgment email to user ${userId}`) + } + } + } catch (emailError) { + // Log error but don't fail the entire webhook - email can be retried + console.error('Error queueing donation acknowledgment email:', emailError) + } + } + } + } + } + } catch (donationError) { + // Log error but don't fail the entire webhook - donation transfer can be retried + console.error('Error processing donation transfer:', donationError) + } + + // Send purchase confirmation email to user + try { + const user = await User.find(userId) + if (!user || !user.get('email')) { + if (process.env.NODE_ENV === 'development') { + console.log(`User ${userId} not found or has no email, skipping purchase confirmation email`) + } + } else { + const group = await Group.find(groupId) + if (!group) { + if (process.env.NODE_ENV === 'development') { + console.log(`Group ${groupId} not found, skipping purchase confirmation email`) + } + } else { + const userLocale = user.getLocale() + const isSubscription = session.mode === 'subscription' && stripeSubscriptionId + const trackId = offering.get('track_id') + const isTrackPurchase = !!trackId + + // Format purchase date + const purchaseDate = new Date(session.created * 1000) + const formattedPurchaseDate = purchaseDate.toLocaleDateString(userLocale === 'es' ? 'es-ES' : 'en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }) + + // Format price + const priceFormatted = StripeService.formatPrice(session.amount_total, session.currency || 'usd') + + // Determine access type + let accessType = 'group' + if (isTrackPurchase) { + accessType = 'track' + } else if (offering.get('access_grants')) { + // Check access_grants to determine type + const accessGrants = typeof offering.get('access_grants') === 'string' + ? JSON.parse(offering.get('access_grants')) + : offering.get('access_grants') + if (accessGrants?.trackIds && accessGrants.trackIds.length > 0) { + accessType = 'track' + } else if (accessGrants?.groupIds && accessGrants.groupIds.length > 1) { + accessType = 'bundle' + } + } + + // Get track info if track purchase + let track = null + let trackName = null + let trackUrl = null + if (isTrackPurchase && trackId) { + track = await Track.find(trackId) + if (track) { + trackName = track.get('name') + trackUrl = Frontend.Route.track(track, group) + } + } + + // Get subscription info if subscription + let renewalDate = null + let renewalPeriod = null + let manageSubscriptionUrl = null + if (isSubscription && stripeSubscriptionId) { + try { + const stripeAccountId = group.get('stripe_account_id') + const getExternalAccountId = async (accountId) => { + if (accountId && accountId.startsWith('acct_')) { + return accountId + } + const StripeAccount = bookshelf.model('StripeAccount') + const stripeAccount = await StripeAccount.where({ id: accountId }).fetch() + if (!stripeAccount) { + throw new Error('Stripe account record not found') + } + return stripeAccount.get('stripe_account_external_id') + } + + const externalAccountId = stripeAccountId ? await getExternalAccountId(stripeAccountId) : null + + const subscription = await stripe.subscriptions.retrieve( + stripeSubscriptionId, + {}, + externalAccountId + ? { stripeAccount: externalAccountId } + : {} + ) + + if (subscription.current_period_end) { + const renewalDateObj = new Date(subscription.current_period_end * 1000) + renewalDate = renewalDateObj.toLocaleDateString(userLocale === 'es' ? 'es-ES' : 'en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }) + } + + // Map subscription interval to renewal period + if (subscription.items?.data?.[0]?.price?.recurring) { + const interval = subscription.items.data[0].price.recurring.interval + const intervalCount = subscription.items.data[0].price.recurring.interval_count || 1 + + if (interval === 'month') { + renewalPeriod = intervalCount === 1 ? 'monthly' : `${intervalCount}-month` + } else if (interval === 'year') { + renewalPeriod = intervalCount === 1 ? 'annual' : `${intervalCount}-year` + } else if (interval === 'week') { + renewalPeriod = intervalCount === 1 ? 'weekly' : `${intervalCount}-week` + } else { + renewalPeriod = interval + } + } + + // Generate manage subscription URL (Stripe customer portal or Hylo settings) + manageSubscriptionUrl = `${process.env.FRONTEND_URL || 'https://hylo.com'}/settings/subscriptions` + } catch (subError) { + console.error('Error fetching subscription details for email:', subError) + // Continue without subscription details + } + } + + // Get receipt URL + let stripeReceiptUrl = null + if (session.invoice) { + try { + const invoice = await stripe.invoices.retrieve(session.invoice) + stripeReceiptUrl = invoice.hosted_invoice_url || invoice.invoice_pdf + } catch (invoiceError) { + // Receipt URL is optional, continue without it + if (process.env.NODE_ENV === 'development') { + console.log('Could not retrieve receipt URL:', invoiceError.message) + } + } + } + + // Check if this is a track purchase - if so, send Track Access Purchased email instead + if (isTrackPurchase && track) { + // Queue Track Access Purchased email + const isEnrolled = accessRecords.some(ar => ar.get('track_id') === trackId) + + Queue.classMethod('Email', 'sendTrackAccessPurchased', { + email: user.get('email'), + data: { + user_name: user.get('name'), + track_name: trackName, + track_description: track.get('description'), + track_url: trackUrl, + track_image_url: track.get('image_url') || group.get('avatar_url'), + group_name: group.get('name'), + group_url: Frontend.Route.group(group), + offering_name: offering.get('name'), + price_formatted: priceFormatted, + purchase_date: formattedPurchaseDate, + is_enrolled: isEnrolled, + start_learning_url: isEnrolled ? `${trackUrl}/actions` : trackUrl, + group_avatar_url: group.get('avatar_url') + }, + version: 'Redesign 2025', + locale: userLocale + }) + + if (process.env.NODE_ENV === 'development') { + console.log(`Queued Track Access Purchased email to user ${userId}`) + } + } else { + // Queue Purchase Confirmation email + const emailData = { + user_name: user.get('name'), + offering_name: offering.get('name'), + offering_description: offering.get('description'), + price_formatted: priceFormatted, + currency: (session.currency || 'usd').toUpperCase(), + purchase_date: formattedPurchaseDate, + access_type: accessType, + group_name: group.get('name'), + group_url: Frontend.Route.group(group), + is_subscription: isSubscription, + group_avatar_url: group.get('avatar_url') + } + + // Add track info if applicable + if (track && trackName && trackUrl) { + emailData.track_name = trackName + emailData.track_url = trackUrl + } + + // Add subscription info if applicable + if (isSubscription) { + if (renewalDate) emailData.renewal_date = renewalDate + if (renewalPeriod) emailData.renewal_period = renewalPeriod + if (manageSubscriptionUrl) emailData.manage_subscription_url = manageSubscriptionUrl + } else { + // Check if one-time purchase has expiration + const duration = offering.get('duration') + if (duration && duration !== 'lifetime') { + // Calculate expiration date based on duration + const expiresAtDate = new Date(purchaseDate) + if (duration === 'day') { + expiresAtDate.setDate(expiresAtDate.getDate() + 1) + } else if (duration === 'month') { + expiresAtDate.setMonth(expiresAtDate.getMonth() + 1) + } else if (duration === 'season') { + expiresAtDate.setMonth(expiresAtDate.getMonth() + 3) + } else if (duration === 'annual') { + expiresAtDate.setFullYear(expiresAtDate.getFullYear() + 1) + } + emailData.expires_at = expiresAtDate.toLocaleDateString(userLocale === 'es' ? 'es-ES' : 'en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }) + } + } + + // Add receipt URL if available + if (stripeReceiptUrl) { + emailData.stripe_receipt_url = stripeReceiptUrl + } + + Queue.classMethod('Email', 'sendPurchaseConfirmation', { + email: user.get('email'), + data: emailData, + version: 'Redesign 2025', + locale: userLocale + }) + + if (process.env.NODE_ENV === 'development') { + console.log(`Queued Purchase Confirmation email to user ${userId}`) + } + } + } + } + } catch (emailError) { + // Log error but don't fail the entire webhook - email queuing can be retried + console.error('Error queueing purchase confirmation email:', emailError) + } + + // TODO STRIPE: Send notification to group admins + } catch (error) { + console.error('Error handling checkout.session.completed:', error) + throw error + } + }, + + /** + * Handle product.updated webhook events + * Syncs product changes from Stripe back to our database + */ + handleProductUpdated: async function (event) { + try { + const product = event.data.object + if (process.env.NODE_ENV === 'development') { + console.log(`Product updated in Stripe: ${product.id}`) + } + + // Find our database record for this product + const dbProduct = await StripeProduct.findByStripeId(product.id) + if (!dbProduct) { + if (process.env.NODE_ENV === 'development') { + console.log(`No database record found for Stripe product: ${product.id}`) + } + return + } + + // Get the current price information + const expandedProduct = await stripe.products.retrieve(product.id, { + expand: ['default_price'] + }) + + // Check if our database record needs updating + const needsUpdate = {} + + if (dbProduct.get('name') !== expandedProduct.name) { + needsUpdate.name = expandedProduct.name + } + + if (dbProduct.get('description') !== expandedProduct.description) { + needsUpdate.description = expandedProduct.description + } + + if (dbProduct.get('price_in_cents') !== expandedProduct.default_price.unit_amount) { + needsUpdate.price_in_cents = expandedProduct.default_price.unit_amount + } + + if (dbProduct.get('currency') !== expandedProduct.default_price.currency) { + needsUpdate.currency = expandedProduct.default_price.currency + } + + if (dbProduct.get('stripe_price_id') !== expandedProduct.default_price) { + needsUpdate.stripe_price_id = expandedProduct.default_price + } + + // Update our database if there are changes + if (Object.keys(needsUpdate).length > 0) { + await dbProduct.save(needsUpdate) + if (process.env.NODE_ENV === 'development') { + console.log(`Updated database record for product ${product.id}:`, needsUpdate) + } + } else { + if (process.env.NODE_ENV === 'development') { + console.log(`Database record for product ${product.id} is already in sync`) + } + } + } catch (error) { + console.error('Error handling product.updated:', error) + throw error + } + }, + + /** + * Handle customer.subscription.created webhook events + * Confirms subscription was created after checkout completes + * Note: Initial access is typically granted by checkout.session.completed + */ + handleSubscriptionCreated: async function (event) { + try { + const subscription = event.data.object + if (process.env.NODE_ENV === 'development') { + console.log(`Subscription created: ${subscription.id}`) + } + + // Check if this subscription already has access records + // (should exist if created via checkout.session.completed) + const existingAccess = await ContentAccess.findBySubscriptionId(subscription.id) + + if (existingAccess && existingAccess.length > 0) { + if (process.env.NODE_ENV === 'development') { + console.log(`Subscription ${subscription.id} already has ${existingAccess.length} access records (created via checkout)`) + } + return + } + + // No access records exist - check if this is expected or an error + // Try to get metadata from subscription first + let offeringId = subscription.metadata?.offeringId + let userId = subscription.metadata?.userId + let sessionId = subscription.metadata?.sessionId + + // If metadata is missing, try to find the checkout session + if (!offeringId || !userId) { + const sessions = await stripe.checkout.sessions.list({ + subscription: subscription.id, + limit: 1 + }) + + if (sessions && sessions.data.length > 0) { + const session = sessions.data[0] + offeringId = offeringId || session.metadata?.offeringId + userId = userId || session.metadata?.userId + sessionId = sessionId || session.id + } + } + + if (!offeringId || !userId) { + console.warn(`Subscription ${subscription.id} missing required metadata (offeringId: ${!!offeringId}, userId: ${!!userId}). Cannot validate or create access.`) + return + } + + // Find the offering to check if access grants are defined + const offering = await StripeProduct.where({ id: offeringId }).fetch() + if (!offering) { + console.warn(`Offering ${offeringId} not found for subscription ${subscription.id}`) + return + } + + const accessGrants = offering.get('access_grants') || {} + + // If access_grants is empty, this is expected (e.g., voluntary contribution) + if (Object.keys(accessGrants).length === 0) { + if (process.env.NODE_ENV === 'development') { + console.log(`Subscription ${subscription.id} has no access grants defined - this is expected for voluntary contributions`) + } + return + } + + // Access grants exist but no access records - something went wrong! + // Create the missing access records now + console.warn(`Subscription ${subscription.id} should have access records but none exist. Creating now...`) + + const userIdNum = parseInt(userId, 10) + const grantedByGroupIdNum = parseInt(offering.get('group_id'), 10) + + // FIRST: Determine which groups need membership from the offering's access_grants + // We need to ensure membership BEFORE assigning roles + const groupsToJoin = new Set() + + // Add the group that owns the product (always grant access to this group) + groupsToJoin.add(grantedByGroupIdNum) + + // Add any groups specified in access_grants.groupIds + if (accessGrants.groupIds && Array.isArray(accessGrants.groupIds)) { + for (const groupId of accessGrants.groupIds) { + const groupIdNum = parseInt(groupId, 10) + if (!isNaN(groupIdNum) && groupIdNum > 0) { + groupsToJoin.add(groupIdNum) + } + } + } + + // If groupRoleIds or commonRoleIds are specified, ensure membership for those groups + if (accessGrants.groupRoleIds || accessGrants.commonRoleIds) { + const groupIdsForRoles = accessGrants.groupIds && Array.isArray(accessGrants.groupIds) && accessGrants.groupIds.length > 0 + ? accessGrants.groupIds.map(id => parseInt(id, 10)).filter(id => !isNaN(id) && id > 0) + : [grantedByGroupIdNum] + for (const groupIdNum of groupIdsForRoles) { + groupsToJoin.add(groupIdNum) + } + } + + // Ensure user is a member of all groups that will receive access BEFORE assigning roles + for (const accessGroupId of groupsToJoin) { + try { + const membership = await GroupMembership.ensureMembership(userIdNum, accessGroupId, { + role: GroupMembership.Role.DEFAULT + }) + + // Record agreement acceptance - user accepted agreements before purchase + if (membership) { + await membership.acceptAgreements() + } + + // Pin the purchased group to the user's global navigation + await GroupMembership.pinGroupToNav(userIdNum, accessGroupId) + + if (process.env.NODE_ENV === 'development') { + console.log(`Ensured group membership for user ${userIdNum} in group ${accessGroupId}`) + } + } catch (error) { + console.error(`Error ensuring membership for user ${userIdNum} in group ${accessGroupId}:`, error) + } + } + + // NOW: Generate content access records and assign roles (membership is already ensured) + const accessRecords = await offering.generateContentAccessRecords({ + userId: userIdNum, + sessionId: sessionId || subscription.id, // Use subscription ID if no session + stripeSubscriptionId: subscription.id, + metadata: { + created_via_webhook: 'customer.subscription.created', + subscription_period_start: new Date(subscription.current_period_start * 1000).toISOString(), + subscription_period_end: new Date(subscription.current_period_end * 1000).toISOString() + } + }) + + if (process.env.NODE_ENV === 'development') { + console.log(`Created ${accessRecords.length} missing access records for subscription ${subscription.id}`) + } + } catch (error) { + console.error('Error handling customer.subscription.created:', error) + throw error + } + }, + + /** + * Handle customer.subscription.updated webhook events + * Updates content access records when subscription status changes + * (e.g., when subscription is set to cancel at period end) + */ + handleSubscriptionUpdated: async function (event) { + try { + const subscription = event.data.object + // Determine if subscription is scheduled to be cancelled + // Stripe has two cancellation modes: + // 1. cancel_at_period_end: true - Cancel at end of billing period + // 2. cancel_at: - Cancel at a specific date + const isScheduledToCancel = subscription.cancel_at_period_end || subscription.cancel_at !== null + const cancelAt = subscription.cancel_at + ? new Date(subscription.cancel_at * 1000) + : (subscription.cancel_at_period_end ? new Date(subscription.current_period_end * 1000) : null) + + if (process.env.NODE_ENV === 'development') { + console.log(`Subscription updated: ${subscription.id}`, { + status: subscription.status, + cancel_at_period_end: subscription.cancel_at_period_end, + cancel_at: subscription.cancel_at, + isScheduledToCancel, + cancelAt + }) + } + + // Find content access records associated with this subscription + const accessRecords = await ContentAccess.findBySubscriptionId(subscription.id) + + if (!accessRecords || accessRecords.length === 0) { + if (process.env.NODE_ENV === 'development') { + console.log(`No content access records found for subscription ${subscription.id}`) + } + return + } + + // Check if subscription is scheduled to be cancelled (either mode) + if (isScheduledToCancel) { + // Subscription has been set to cancel at period end + // Keep status as ACTIVE (access continues until period end) + // But update metadata to indicate cancellation is scheduled + await Promise.all(accessRecords.map(async (access) => { + const existingMetadata = access.get('metadata') || {} + const updatedMetadata = { + ...existingMetadata, + subscription_cancellation_scheduled_at: new Date().toISOString(), + subscription_cancel_at_period_end: true, + subscription_period_end: cancelAt.toISOString(), + subscription_cancel_reason: subscription.cancellation_details?.reason || 'User requested cancellation' + } + + if (process.env.NODE_ENV === 'development') { + console.log(`Updating content_access ${access.id} with cancellation metadata:`, { + metadata_before: existingMetadata, + metadata_after: updatedMetadata + }) + } + + // Update using raw knex to ensure JSONB is properly updated + await bookshelf.knex('content_access') + .where({ id: access.id }) + .update({ + metadata: JSON.stringify(updatedMetadata), + updated_at: new Date() + }) + + if (process.env.NODE_ENV === 'development') { + console.log(`Successfully updated content_access ${access.id}`) + } + })) + + if (process.env.NODE_ENV === 'development') { + console.log(`Marked ${accessRecords.length} access records for subscription ${subscription.id} as scheduled to cancel at ${cancelAt.toISOString()}`) + } + } else if (subscription.status === 'active' && !isScheduledToCancel) { + // Subscription was reactivated (cancellation was undone) + // Only clear metadata if it was previously set + await Promise.all(accessRecords.map(async (access) => { + const existingMetadata = access.get('metadata') || {} + const hadCancellationMetadata = existingMetadata.subscription_cancel_at_period_end + + if (hadCancellationMetadata) { + // Create a new metadata object without cancellation fields + const updatedMetadata = { ...existingMetadata } + delete updatedMetadata.subscription_cancellation_scheduled_at + delete updatedMetadata.subscription_cancel_at_period_end + delete updatedMetadata.subscription_cancel_reason + delete updatedMetadata.subscription_period_end + + if (process.env.NODE_ENV === 'development') { + console.log(`Clearing cancellation metadata from content_access ${access.id}:`, { + metadata_before: existingMetadata, + metadata_after: updatedMetadata + }) + } + + // Update using raw knex to ensure JSONB is properly updated + await bookshelf.knex('content_access') + .where({ id: access.id }) + .update({ + metadata: JSON.stringify(updatedMetadata), + updated_at: new Date() + }) + + if (process.env.NODE_ENV === 'development') { + console.log(`Successfully cleared metadata from content_access ${access.id}`) + } + } + })) + + if (process.env.NODE_ENV === 'development') { + console.log(`Cleared cancellation metadata for ${accessRecords.length} access records - subscription ${subscription.id} reactivated (status: ${subscription.status}, cancel_at_period_end: ${subscription.cancel_at_period_end})`) + } + } else { + // Subscription status changed but not to cancel_at_period_end or reactivated + // Log for debugging + if (process.env.NODE_ENV === 'development') { + console.log(`Subscription ${subscription.id} updated but no action taken (status: ${subscription.status}, cancel_at_period_end: ${subscription.cancel_at_period_end})`) + } + } + } catch (error) { + console.error('Error handling customer.subscription.updated:', error) + throw error + } + }, + + /** + * Handle customer.subscription.deleted webhook events + * Expires access when subscription is canceled or ends + */ + handleSubscriptionDeleted: async function (event) { + try { + const subscription = event.data.object + if (process.env.NODE_ENV === 'development') { + console.log(`Subscription deleted: ${subscription.id}`) + } + + // Find content access records associated with this subscription + const accessRecords = await ContentAccess.findBySubscriptionId(subscription.id) + + if (!accessRecords || accessRecords.length === 0) { + if (process.env.NODE_ENV === 'development') { + console.log(`No content access records found for subscription ${subscription.id}`) + } + return + } + + // Update all associated records to expired status (unless already refunded/revoked) + await Promise.all(accessRecords.map(async (access) => { + const currentStatus = access.get('status') + + // Don't overwrite REFUNDED or REVOKED status - those are intentional admin actions + if (currentStatus === ContentAccess.Status.REFUNDED || currentStatus === ContentAccess.Status.REVOKED) { + if (process.env.NODE_ENV === 'development') { + console.log(`Skipping content_access ${access.id} - already ${currentStatus}`) + } + return + } + + const metadata = access.get('metadata') || {} + metadata.subscription_canceled_at = new Date().toISOString() + metadata.subscription_cancel_reason = subscription.cancellation_details?.reason || 'Subscription ended' + + await access.save({ + status: ContentAccess.Status.EXPIRED, + metadata + }, { patch: true }) + })) + + if (process.env.NODE_ENV === 'development') { + console.log(`Expired ${accessRecords.length} access records for deleted subscription ${subscription.id}`) + } + + // Send Subscription Cancelled email + try { + const firstAccess = accessRecords.at(0) + const userId = firstAccess.get('user_id') + const productId = firstAccess.get('product_id') + const groupId = firstAccess.get('granted_by_group_id') + + const user = await User.find(userId) + const offering = await StripeProduct.where({ id: productId }).fetch() + const group = await Group.find(groupId) + + if (!user || !offering || !group) { + if (process.env.NODE_ENV === 'development') { + console.log('Missing user, offering, or group for subscription cancelled email. Skipping.') + } + } else { + const userLocale = user.getLocale() + const cancelledAt = new Date() + const cancelledAtFormatted = cancelledAt.toLocaleDateString( + userLocale === 'es' ? 'es-ES' : 'en-US', + { + year: 'numeric', + month: 'long', + day: 'numeric' + } + ) + + // Determine when access ends + // If cancelled at period end, access ends at current_period_end + // If immediate cancellation, access ends now + let accessEndsAt = cancelledAt + if (subscription.canceled_at && subscription.current_period_end) { + // Check if cancellation was scheduled (cancel_at_period_end was true) + // In that case, access ends at period end + const canceledAtTimestamp = subscription.canceled_at * 1000 + const periodEndTimestamp = subscription.current_period_end * 1000 + if (periodEndTimestamp > canceledAtTimestamp) { + // Cancellation was scheduled, access ends at period end + accessEndsAt = new Date(periodEndTimestamp) + } + } + + const accessEndsAtFormatted = accessEndsAt.toLocaleDateString( + userLocale === 'es' ? 'es-ES' : 'en-US', + { + year: 'numeric', + month: 'long', + day: 'numeric' + } + ) + + // Get cancellation reason + let reason = null + if (subscription.cancellation_details?.reason) { + reason = subscription.cancellation_details.reason + } else if (subscription.cancellation_details?.feedback) { + reason = subscription.cancellation_details.feedback + } + + const emailData = { + user_name: user.get('name'), + offering_name: offering.get('name'), + group_name: group.get('name'), + group_url: Frontend.Route.group(group), + cancelled_at: cancelledAtFormatted, + access_ends_at: accessEndsAtFormatted, + resubscribe_url: Frontend.Route.group(group), + group_avatar_url: group.get('avatar_url') + } + + if (reason) { + emailData.reason = reason + } + + Queue.classMethod('Email', 'sendSubscriptionCancelled', { + email: user.get('email'), + data: emailData, + version: 'Redesign 2025', + locale: userLocale + }) + + if (process.env.NODE_ENV === 'development') { + console.log(`Queued Subscription Cancelled email to user ${userId}`) + } + + // Send Admin Notification emails to all admins/stewards + try { + // Get all admins with RESP_ADMINISTRATION (ID = 1) + const admins = await group.membersWithResponsibilities([Responsibility.Common.RESP_ADMINISTRATION]).fetch() + + if (admins && admins.models && admins.models.length > 0) { + // Determine cancellation type + const cancellationType = accessEndsAt > cancelledAt ? 'at_period_end' : 'immediate' + + // Get subscription amount and period from offering + const priceInCents = offering.get('price_in_cents') || 0 + const currency = offering.get('currency') || 'usd' + const subscriptionAmount = StripeService.formatPrice(priceInCents, currency) + + const duration = offering.get('duration') + let subscriptionPeriod = null + if (duration === 'day') { + subscriptionPeriod = 'daily' + } else if (duration === 'month') { + subscriptionPeriod = 'monthly' + } else if (duration === 'season') { + subscriptionPeriod = 'quarterly' + } else if (duration === 'annual') { + subscriptionPeriod = 'annual' + } else if (duration) { + subscriptionPeriod = duration + } + + // Send email to each admin individually + await Promise.all(admins.models.map(async (adminMembership) => { + const admin = adminMembership.relations.user || await User.find(adminMembership.get('user_id')) + if (!admin) return + + const adminLocale = admin.getLocale() + + const cancelledAtFormattedForAdmin = cancelledAt.toLocaleDateString( + adminLocale === 'es' ? 'es-ES' : 'en-US', + { + year: 'numeric', + month: 'long', + day: 'numeric' + } + ) + + const accessEndsAtFormattedForAdmin = accessEndsAt.toLocaleDateString( + adminLocale === 'es' ? 'es-ES' : 'en-US', + { + year: 'numeric', + month: 'long', + day: 'numeric' + } + ) + + const adminEmailData = { + admin_name: admin.get('name'), + user_name: user.get('name'), + user_email: user.get('email'), + user_profile_url: Frontend.Route.profile(user), + offering_name: offering.get('name'), + group_name: group.get('name'), + group_url: Frontend.Route.group(group), + cancelled_at: cancelledAtFormattedForAdmin, + access_ends_at: accessEndsAtFormattedForAdmin, + cancellation_type: cancellationType, + subscription_amount: subscriptionAmount, + subscription_period: subscriptionPeriod, + revenue_lost: subscriptionAmount, + view_content_access_url: `${process.env.FRONTEND_URL || 'https://hylo.com'}/groups/${group.get('slug') || group.id}/settings/paid-content/access`, + contact_user_url: `${Frontend.Route.profile(user)}/message`, + group_avatar_url: group.get('avatar_url') + } + + if (reason) { + adminEmailData.reason = reason + } + + Queue.classMethod('Email', 'sendSubscriptionCancelledAdminNotification', { + email: admin.get('email'), + data: adminEmailData, + version: 'Redesign 2025', + locale: adminLocale + }) + + if (process.env.NODE_ENV === 'development') { + console.log(`Queued Subscription Cancelled Admin Notification email to admin ${admin.id}`) + } + })) + } + } catch (adminEmailError) { + // Log error but don't fail the entire webhook - email can be retried + console.error('Error queueing subscription cancelled admin notification emails:', adminEmailError) + } + } + } catch (emailError) { + // Log error but don't fail the entire webhook - email can be retried + console.error('Error queueing subscription cancelled email:', emailError) + } + } catch (error) { + console.error('Error handling customer.subscription.deleted:', error) + throw error + } + }, + + /** + * Handle invoice.paid webhook events + * Extends access when subscription renewal payment succeeds + */ + handleInvoicePaid: async function (event) { + try { + const invoice = event.data.object + if (process.env.NODE_ENV === 'development') { + console.log(`Invoice paid: ${invoice.id}`) + } + + // Only handle invoices for subscriptions + if (!invoice.subscription) { + if (process.env.NODE_ENV === 'development') { + console.log(`Invoice ${invoice.id} is not for a subscription, skipping`) + } + return + } + + // Check if this is the first invoice (initial payment) + // Initial payment is handled by checkout.session.completed + if (invoice.billing_reason === 'subscription_create') { + if (process.env.NODE_ENV === 'development') { + console.log(`Invoice ${invoice.id} is for initial subscription creation, already handled by checkout`) + } + return + } + + const subscriptionId = invoice.subscription + + // Find content access records for this subscription + const accessRecords = await ContentAccess.findBySubscriptionId(subscriptionId) + + if (!accessRecords || accessRecords.length === 0) { + if (process.env.NODE_ENV === 'development') { + console.log(`No content access records found for subscription ${subscriptionId}`) + } + return + } + + // Check renewal_policy from the offering + const firstAccess = accessRecords.at(0) + const productId = firstAccess.get('product_id') + + if (productId) { + const offering = await StripeProduct.where({ id: productId }).fetch() + + if (offering && offering.get('renewal_policy') === StripeProduct.RenewalPolicy.MANUAL) { + // This offering should not auto-renew + console.warn(`Subscription ${subscriptionId} attempted to renew but offering ${productId} has renewal_policy='manual'. Canceling subscription.`) + + // Cancel the subscription at period end + await stripe.subscriptions.update(subscriptionId, { + cancel_at_period_end: true + }) + + // Don't extend access - let it expire naturally + if (process.env.NODE_ENV === 'development') { + console.log(`Subscription ${subscriptionId} set to cancel at period end. Access will expire.`) + } + + return + } + } + + // Get subscription details to get the new period end + const subscription = await stripe.subscriptions.retrieve(subscriptionId) + const newExpiresAt = new Date(subscription.current_period_end * 1000) + + // Extend access for all associated records + await Promise.all(accessRecords.map(async (access) => { + await ContentAccess.extendAccess( + access.id, + newExpiresAt, + { + renewed_at: new Date().toISOString(), + invoice_id: invoice.id, + subscription_period_start: new Date(subscription.current_period_start * 1000).toISOString(), + subscription_period_end: newExpiresAt.toISOString(), + billing_reason: invoice.billing_reason + } + ) + })) + + if (process.env.NODE_ENV === 'development') { + console.log(`Extended ${accessRecords.length} access records for subscription ${subscriptionId} until ${newExpiresAt.toISOString()}`) + } + + // Send Subscription Renewed email + try { + const firstAccess = accessRecords.at(0) + const userId = firstAccess.get('user_id') + const productId = firstAccess.get('product_id') + const groupId = firstAccess.get('granted_by_group_id') + + const user = await User.find(userId) + const offering = await StripeProduct.where({ id: productId }).fetch() + const group = await Group.find(groupId) + + if (!user || !offering || !group) { + if (process.env.NODE_ENV === 'development') { + console.log('Missing user, offering, or group for subscription renewal email. Skipping.') + } + } else { + const userLocale = user.getLocale() + const paymentDate = new Date(invoice.created * 1000) + const paymentDateFormatted = paymentDate.toLocaleDateString( + userLocale === 'es' ? 'es-ES' : 'en-US', + { + year: 'numeric', + month: 'long', + day: 'numeric' + } + ) + + const nextRenewalDateFormatted = newExpiresAt.toLocaleDateString( + userLocale === 'es' ? 'es-ES' : 'en-US', + { + year: 'numeric', + month: 'long', + day: 'numeric' + } + ) + + const amountPaid = StripeService.formatPrice(invoice.amount_paid, invoice.currency || 'usd') + + // Get receipt URL + let stripeReceiptUrl = null + if (invoice.hosted_invoice_url) { + stripeReceiptUrl = invoice.hosted_invoice_url + } else if (invoice.invoice_pdf) { + stripeReceiptUrl = invoice.invoice_pdf + } + + const emailData = { + user_name: user.get('name'), + offering_name: offering.get('name'), + group_name: group.get('name'), + group_url: Frontend.Route.group(group), + amount_paid: amountPaid, + payment_date: paymentDateFormatted, + next_renewal_date: nextRenewalDateFormatted, + manage_subscription_url: `${process.env.FRONTEND_URL || 'https://hylo.com'}/settings/subscriptions`, + group_avatar_url: group.get('avatar_url') + } + + if (stripeReceiptUrl) { + emailData.stripe_receipt_url = stripeReceiptUrl + } + + Queue.classMethod('Email', 'sendSubscriptionRenewed', { + email: user.get('email'), + data: emailData, + version: 'Redesign 2025', + locale: userLocale + }) + + if (process.env.NODE_ENV === 'development') { + console.log(`Queued Subscription Renewed email to user ${userId}`) + } + } + } catch (emailError) { + // Log error but don't fail the entire webhook - email can be retried + console.error('Error queueing subscription renewed email:', emailError) + } + + // Handle recurring donations from subscription renewal invoices + // Check invoice line items for recurring donations + let donationAmount = 0 + try { + // Get group ID from the first access record to find connected account + const firstAccess = accessRecords.at(0) + const productId = firstAccess.get('product_id') + + if (productId) { + const offering = await StripeProduct.where({ id: productId }).fetch() + if (offering) { + const groupId = offering.get('group_id') + const group = await Group.find(groupId) + + if (group) { + const stripeAccountId = group.get('stripe_account_id') + if (stripeAccountId) { + // Convert database ID to external account ID if needed + const getExternalAccountId = async (accountId) => { + if (accountId && accountId.startsWith('acct_')) { + return accountId + } + const StripeAccount = bookshelf.model('StripeAccount') + const stripeAccount = await StripeAccount.where({ id: accountId }).fetch() + if (!stripeAccount) { + throw new Error('Stripe account record not found') + } + return stripeAccount.get('stripe_account_external_id') + } + + const externalAccountId = await getExternalAccountId(stripeAccountId) + + // Retrieve invoice with line items expanded + const fullInvoice = await stripe.invoices.retrieve(invoice.id, { + expand: ['lines.data.price.product'] + }, { + stripeAccount: externalAccountId + }) + + // Look for recurring donation line items in the invoice + // Track donation details for acknowledgment email + const donationDetails = { + isRecurring: true, + recurringInterval: null, + donationType: 'recurring' + } + + if (fullInvoice.lines?.data) { + for (const lineItem of fullInvoice.lines.data) { + const productName = lineItem.price?.product?.name || lineItem.description || '' + const isRecurringDonation = productName.toLowerCase().includes('recurring donation to hylo') + + if (isRecurringDonation) { + // Calculate donation amount: unit_amount * quantity + const itemDonationAmount = (lineItem.price.unit_amount || 0) * (lineItem.quantity || 0) + donationAmount += itemDonationAmount + + // Track recurring interval + if (lineItem.price?.recurring) { + const interval = lineItem.price.recurring.interval + if (interval === 'month') { + donationDetails.recurringInterval = 'monthly' + } else if (interval === 'year') { + donationDetails.recurringInterval = 'annually' + } else if (interval === 'week') { + donationDetails.recurringInterval = 'weekly' + } else if (interval === 'day') { + donationDetails.recurringInterval = 'daily' + } else { + donationDetails.recurringInterval = interval + } + } + + if (process.env.NODE_ENV === 'development') { + console.log(`Found recurring donation in renewal invoice: ${itemDonationAmount} ${invoice.currency || 'usd'}`) + } + } + } + } + + // Transfer recurring donation if present + if (donationAmount > 0) { + // For invoices, we need to get the charge ID from the payment intent + const paymentIntentId = invoice.payment_intent + + if (paymentIntentId) { + await StripeService.transferDonationToPlatform({ + connectedAccountId: externalAccountId, + paymentIntentId, + donationAmount, + currency: invoice.currency || 'usd' + }) + + if (process.env.NODE_ENV === 'development') { + console.log(`Transferred recurring donation of ${donationAmount} ${invoice.currency || 'usd'} to platform from subscription renewal`) + } + + // Send Donation Acknowledgment email for recurring donation + try { + const firstAccess = accessRecords.at(0) + const userId = firstAccess.get('user_id') + const user = await User.find(userId) + + if (user && user.get('email')) { + const userLocale = user.getLocale() + const donationDate = new Date(invoice.created * 1000) + const donationDateFormatted = donationDate.toLocaleDateString( + userLocale === 'es' ? 'es-ES' : 'en-US', + { + year: 'numeric', + month: 'long', + day: 'numeric' + } + ) + + const donationAmountFormatted = StripeService.formatPrice(donationAmount, invoice.currency || 'usd') + + // Get group and offering info for context + const productId = firstAccess.get('product_id') + const offering = productId ? await StripeProduct.where({ id: productId }).fetch() : null + let purchaseContext = null + if (offering) { + purchaseContext = offering.get('name') + } + + // Determine next donation date (based on subscription renewal date) + let nextDonationDate = null + if (donationDetails.recurringInterval) { + // Get subscription to find next billing date + try { + const subscription = await stripe.subscriptions.retrieve(invoice.subscription, { + stripeAccount: externalAccountId + }) + if (subscription.current_period_end) { + const nextDate = new Date(subscription.current_period_end * 1000) + nextDonationDate = nextDate.toLocaleDateString( + userLocale === 'es' ? 'es-ES' : 'en-US', + { + year: 'numeric', + month: 'long', + day: 'numeric' + } + ) + } + } catch (subError) { + // Fallback: calculate from interval + const nextDate = new Date(donationDate) + if (donationDetails.recurringInterval === 'monthly') { + nextDate.setMonth(nextDate.getMonth() + 1) + } else if (donationDetails.recurringInterval === 'annually') { + nextDate.setFullYear(nextDate.getFullYear() + 1) + } else if (donationDetails.recurringInterval === 'weekly') { + nextDate.setDate(nextDate.getDate() + 7) + } else if (donationDetails.recurringInterval === 'daily') { + nextDate.setDate(nextDate.getDate() + 1) + } + nextDonationDate = nextDate.toLocaleDateString( + userLocale === 'es' ? 'es-ES' : 'en-US', + { + year: 'numeric', + month: 'long', + day: 'numeric' + } + ) + } + } + + // Fiscal sponsor info (use env var or default) + const fiscalSponsorName = process.env.FISCAL_SPONSOR_NAME || 'our fiscal sponsor' + const localeObj = locales[userLocale] || locales.en + const taxReceiptInfo = localeObj.donationTaxReceiptInfo() + const impactMessage = localeObj.donationRecurringImpactMessage() + + const emailData = { + user_name: user.get('name'), + donation_amount: donationAmountFormatted, + donation_type: donationDetails.donationType, + donation_date: donationDateFormatted, + is_tax_deductible: true, + fiscal_sponsor_name: fiscalSponsorName, + tax_receipt_info: taxReceiptInfo, + is_recurring: true, + impact_message: impactMessage + } + + if (nextDonationDate) { + emailData.next_donation_date = nextDonationDate + } + if (donationDetails.recurringInterval) { + emailData.recurring_interval = donationDetails.recurringInterval + } + emailData.manage_donation_url = `${process.env.FRONTEND_URL || 'https://hylo.com'}/settings/subscriptions` + + if (purchaseContext) { + emailData.purchase_context = purchaseContext + } + + if (group) { + emailData.group_name = group.get('name') + } + + Queue.classMethod('Email', 'sendDonationAcknowledgment', { + email: user.get('email'), + data: emailData, + version: 'Redesign 2025', + locale: userLocale + }) + + if (process.env.NODE_ENV === 'development') { + console.log(`Queued Donation Acknowledgment email to user ${userId} for recurring donation`) + } + } + } catch (emailError) { + // Log error but don't fail the entire webhook - email can be retried + console.error('Error queueing donation acknowledgment email for recurring donation:', emailError) + } + } + } + } + } + } + } + } catch (donationError) { + // Log error but don't fail the entire webhook - donation transfer can be retried + console.error('Error processing recurring donation from invoice:', donationError) + } + } catch (error) { + console.error('Error handling invoice.paid:', error) + throw error + } + }, + + /** + * Handle invoice.payment_failed webhook events + * Logs failed subscription renewal payments + */ + handleInvoicePaymentFailed: async function (event) { + try { + const invoice = event.data.object + if (process.env.NODE_ENV === 'development') { + console.log(`Invoice payment failed: ${invoice.id}`) + } + + // Only handle invoices for subscriptions + if (!invoice.subscription) { + return + } + + const subscriptionId = invoice.subscription + + // Find content access records for this subscription + const accessRecords = await ContentAccess.findBySubscriptionId(subscriptionId) + + if (!accessRecords || accessRecords.length === 0) { + if (process.env.NODE_ENV === 'development') { + console.log(`No content access records found for subscription ${subscriptionId}`) + } + return + } + + // Log the failed payment in metadata (don't revoke access yet - Stripe retries) + await Promise.all(accessRecords.map(async (access) => { + const metadata = access.get('metadata') || {} + metadata.last_payment_failure = new Date().toISOString() + metadata.last_payment_failure_invoice = invoice.id + + await access.save({ metadata }) + })) + + console.warn(`Payment failed for subscription ${subscriptionId} affecting ${accessRecords.length} access records. Stripe will retry payment.`) + + // Send Payment Failed email + try { + const firstAccess = accessRecords.at(0) + const userId = firstAccess.get('user_id') + const productId = firstAccess.get('product_id') + const groupId = firstAccess.get('granted_by_group_id') + + const user = await User.find(userId) + const offering = await StripeProduct.where({ id: productId }).fetch() + const group = await Group.find(groupId) + + if (!user || !offering || !group) { + if (process.env.NODE_ENV === 'development') { + console.log('Missing user, offering, or group for payment failed email. Skipping.') + } + } else { + // Get subscription to find current period end (when access will end) + const groupStripeAccountId = group.get('stripe_account_id') + let subscription = null + let accessEndsDate = null + + if (groupStripeAccountId) { + try { + const getExternalAccountId = async (accountId) => { + if (accountId && accountId.startsWith('acct_')) { + return accountId + } + const StripeAccount = bookshelf.model('StripeAccount') + const stripeAccount = await StripeAccount.where({ id: accountId }).fetch() + if (!stripeAccount) { + throw new Error('Stripe account record not found') + } + return stripeAccount.get('stripe_account_external_id') + } + + const externalAccountId = await getExternalAccountId(groupStripeAccountId) + subscription = await stripe.subscriptions.retrieve( + subscriptionId, + {}, + externalAccountId ? { stripeAccount: externalAccountId } : {} + ) + + if (subscription.current_period_end) { + accessEndsDate = new Date(subscription.current_period_end * 1000) + } + } catch (subError) { + console.error('Error fetching subscription for payment failed email:', subError) + // Continue without subscription details + } + } + + const userLocale = user.getLocale() + + // Get failure reason + let failureReason = 'Payment could not be processed' + if (invoice.last_payment_error?.message) { + failureReason = invoice.last_payment_error.message + } else if (invoice.payment_intent) { + try { + const paymentIntent = await stripe.paymentIntents.retrieve(invoice.payment_intent) + if (paymentIntent.last_payment_error?.message) { + failureReason = paymentIntent.last_payment_error.message + } + } catch (piError) { + // Continue with default message + if (process.env.NODE_ENV === 'development') { + console.log('Could not retrieve payment intent for failure reason:', piError.message) + } + } + } + + // Get retry date if Stripe will retry + let retryDateFormatted = null + if (invoice.next_payment_attempt) { + const retryDate = new Date(invoice.next_payment_attempt * 1000) + retryDateFormatted = retryDate.toLocaleDateString( + userLocale === 'es' ? 'es-ES' : 'en-US', + { + year: 'numeric', + month: 'long', + day: 'numeric' + } + ) + } + + // Format access ends date + let accessEndsDateFormatted = null + if (accessEndsDate) { + accessEndsDateFormatted = accessEndsDate.toLocaleDateString( + userLocale === 'es' ? 'es-ES' : 'en-US', + { + year: 'numeric', + month: 'long', + day: 'numeric' + } + ) + } + + const emailData = { + user_name: user.get('name'), + offering_name: offering.get('name'), + group_name: group.get('name'), + group_url: Frontend.Route.group(group), + failure_reason: failureReason, + manage_subscription_url: `${process.env.FRONTEND_URL || 'https://hylo.com'}/settings/subscriptions`, + update_payment_url: `${process.env.FRONTEND_URL || 'https://hylo.com'}/settings/subscriptions`, + group_avatar_url: group.get('avatar_url') + } + + if (retryDateFormatted) { + emailData.retry_date = retryDateFormatted + } + + if (accessEndsDateFormatted) { + emailData.access_ends_date = accessEndsDateFormatted + } + + Queue.classMethod('Email', 'sendPaymentFailed', { + email: user.get('email'), + data: emailData, + version: 'Redesign 2025', + locale: userLocale + }) + + if (process.env.NODE_ENV === 'development') { + console.log(`Queued Payment Failed email to user ${userId}`) + } + } + } catch (emailError) { + // Log error but don't fail the entire webhook - email can be retried + console.error('Error queueing payment failed email:', emailError) + } + } catch (error) { + console.error('Error handling invoice.payment_failed:', error) + throw error + } + }, + + /** + * Handle charge.refunded webhook events + * Revokes access and cancels any associated subscriptions when payment is refunded + * + * Note: This handles refunds initiated directly through Stripe dashboard. + * Refunds initiated through our refundContentAccess mutation will also trigger this, + * but the access records will already be marked as refunded. + */ + handleChargeRefunded: async function (event) { + try { + const charge = event.data.object + // For Connect webhooks, event.account contains the connected account ID + const connectedAccountId = event.account + + if (process.env.NODE_ENV === 'development') { + console.log(`Charge refunded: ${charge.id} on account: ${connectedAccountId || 'platform'}`) + } + + // Get the payment intent to find the checkout session + const paymentIntentId = charge.payment_intent + if (!paymentIntentId) { + if (process.env.NODE_ENV === 'development') { + console.log(`No payment intent found for charge ${charge.id}`) + } + return + } + + // Retrieve the payment intent to get session_id from metadata + // Must use the connected account header for Connect webhooks + const retrieveOptions = connectedAccountId ? { stripeAccount: connectedAccountId } : {} + const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId, {}, retrieveOptions) + const sessionId = paymentIntent.metadata?.session_id + + if (!sessionId) { + if (process.env.NODE_ENV === 'development') { + console.log(`No session_id found in payment intent metadata for ${paymentIntentId}`) + } + return + } + + // Find content access records associated with this session + const accessRecords = await ContentAccess.findBySessionId(sessionId) + + if (!accessRecords || accessRecords.length === 0) { + if (process.env.NODE_ENV === 'development') { + console.log(`No content access records found for session ${sessionId}`) + } + return + } + + // Revoke/refund all associated access records + // Skip records that are already refunded (e.g., from our mutation) + await Promise.all(accessRecords.map(async (access) => { + const currentStatus = access.get('status') + + // Skip if already refunded or revoked (our mutation already handled this) + if (currentStatus === ContentAccess.Status.REFUNDED || currentStatus === ContentAccess.Status.REVOKED) { + if (process.env.NODE_ENV === 'development') { + console.log(`Access ${access.id} already ${currentStatus}, skipping webhook processing`) + } + return + } + + const reason = charge.refund?.reason || 'Payment refunded via Stripe' + + // Use the revoke method which handles subscription cancellation + await ContentAccess.revoke(access.id, null, reason) + + // Update status to REFUNDED and add metadata with refund details + const metadata = access.get('metadata') || {} + metadata.refunded_at = new Date().toISOString() + metadata.refund_amount = charge.amount_refunded + metadata.refund_reason = reason + metadata.refund_charge_id = charge.id + metadata.refund_source = 'stripe_webhook' + + await access.save({ + status: ContentAccess.Status.REFUNDED, + metadata + }, { patch: true }) + })) + + if (process.env.NODE_ENV === 'development') { + console.log(`Processed ${accessRecords.length} access records for refunded charge ${charge.id}`) + } + } catch (error) { + console.error('Error handling charge.refunded:', error) + throw error + } + }, + + /** + * Health check endpoint for Stripe integration + * + * Verifies that Stripe is properly configured + * + * GET /noo/stripe/health + */ + health: function (req, res) { + const hasStripeKey = !!process.env.STRIPE_SECRET_KEY + + return res.json({ + status: hasStripeKey ? 'configured' : 'not_configured', + message: hasStripeKey + ? 'Stripe is properly configured' + : 'STRIPE_SECRET_KEY environment variable is not set', + apiVersion: '2025-09-30.clover' + }) + } +} diff --git a/apps/backend/api/graphql/makeModels.js b/apps/backend/api/graphql/makeModels.js index 1b7f2cecb5..13339fc61c 100644 --- a/apps/backend/api/graphql/makeModels.js +++ b/apps/backend/api/graphql/makeModels.js @@ -1,4 +1,4 @@ -/* global FundingRound */ +/* global FundingRound ContentAccess */ import { camelCase, isNil, mapKeys, startCase } from 'lodash/fp' import pluralize from 'pluralize' import { TextHelpers } from '@hylo/shared' @@ -17,9 +17,11 @@ import { import { LOCATION_DISPLAY_PRECISION } from '../../lib/constants' import InvitationService from '../services/InvitationService' import { + filterAndSortContentAccess, filterAndSortPosts, filterAndSortUsers } from '../services/Search/util' +const { createGroupRoleScope } = require('../../lib/scopes') // this defines what subset of attributes and relations in each Bookshelf model // should be exposed through GraphQL, and what query filters should be applied @@ -201,6 +203,7 @@ export default function makeModels (userId, isAdmin, apiClient) { model: GroupMembership, attributes: [ 'created_at', + 'expires_at', 'group_id', 'nav_order' ], @@ -257,6 +260,7 @@ export default function makeModels (userId, isAdmin, apiClient) { model: MemberGroupRole, attributes: [ 'id', + 'expires_at', 'group_id', 'group_role_id', 'user_id' @@ -617,9 +621,14 @@ export default function makeModels (userId, isAdmin, apiClient) { 'geo_shape', 'memberCount', 'name', + 'paywall', 'postCount', 'purpose', 'slug', + 'stripe_account_id', + 'stripe_charges_enabled', + 'stripe_payouts_enabled', + 'stripe_details_submitted', 'type', 'visibility', 'website_url', @@ -778,6 +787,24 @@ export default function makeModels (userId, isAdmin, apiClient) { }) } }, + { + contentAccess: { + querySet: true, + filter: (relation, { search, accessType, status, offeringId, trackId, groupRoleId, commonRoleId, sortBy, order }) => + relation.query(filterAndSortContentAccess({ + groupIds: [relation.relatedData.parentId], + search, + accessType, + status, + offeringId, + trackId, + groupRoleId, + commonRoleId, + sortBy, + order + })) + } + }, { viewPosts: { querySet: true, @@ -808,7 +835,9 @@ export default function makeModels (userId, isAdmin, apiClient) { getters: { eventCalendarUrl: g => g.eventCalendarUrl(), // commonRoles: async g => g.commonRoles(), + canAccess: g => g ? g.canAccess(userId) : false, homeWidget: g => g.homeWidget(), + stripeDashboardUrl: g => g.stripeDashboardUrl(), invitePath: g => userId && GroupMembership.hasResponsibility(userId, g, Responsibility.constants.RESP_ADD_MEMBERS) .then(canInvite => canInvite ? Frontend.Route.invitePath(g) : null), @@ -962,7 +991,15 @@ export default function makeModels (userId, isAdmin, apiClient) { relations: [ 'group', { responsibilities: { querySet: true } } - ] + ], + getters: { + canAccess: gr => { + if (!gr || !userId) return false + // Check if user has the group_role scope + const requiredScope = createGroupRoleScope(gr.get('id')) + return UserScope.canAccess(userId, requiredScope) + } + } }, CustomView: { @@ -1281,6 +1318,7 @@ export default function makeModels (userId, isAdmin, apiClient) { Track: { model: Track, attributes: [ + 'access_controlled', 'action_descriptor', 'action_descriptor_plural', 'created_at', @@ -1305,9 +1343,11 @@ export default function makeModels (userId, isAdmin, apiClient) { { users: { querySet: true } } ], getters: { + canAccess: t => t ? t.canAccess(userId) : false, isEnrolled: t => t && userId && t.isEnrolled(userId), didComplete: t => t && userId && t.didComplete(userId), - userSettings: t => t && userId ? t.userSettings(userId) : null + userSettings: t => t && userId ? t.userSettings(userId) : null, + }, fetchMany: ({ autocomplete, first = 20, offset = 0, order, published, sortBy }) => searchQuerySet('tracks', { @@ -1541,6 +1581,204 @@ export default function makeModels (userId, isAdmin, apiClient) { 'id', 'name' ] + }, + + StripeOffering: { + model: StripeProduct, + attributes: [ + 'id', + 'created_at', + 'updated_at', + 'group_id', + 'stripe_product_id', + 'stripe_price_id', + 'name', + 'description', + 'price_in_cents', + 'currency', + 'track_id', + 'access_grants', + 'renewal_policy', + 'duration', + 'publish_status' + ], + relations: [ + 'group', + 'track' + ], + getters: { + stripeProductId: sp => sp.get('stripe_product_id'), + stripePriceId: sp => sp.get('stripe_price_id'), + priceInCents: sp => sp.get('price_in_cents'), + trackId: sp => sp.get('track_id'), + accessGrants: sp => sp.get('access_grants'), + renewalPolicy: sp => sp.get('renewal_policy'), + publishStatus: sp => sp.get('publish_status'), + tracks: async (sp) => { + if (!sp) return [] + const accessGrants = sp.get('access_grants') + if (!accessGrants) return [] + + // Parse accessGrants (might be string or object) + let grants = {} + if (typeof accessGrants === 'string') { + try { + grants = JSON.parse(accessGrants) + } catch { + return [] + } + } else { + grants = accessGrants + } + + // Extract all trackIds from all groups + const trackIds = [] + if (grants.trackIds && Array.isArray(grants.trackIds)) { + trackIds.push(...grants.trackIds.map(id => parseInt(id))) + } + + if (trackIds.length === 0) return [] + + // Fetch tracks + const tracks = await Track.where('id', 'in', trackIds).fetchAll() + return tracks.models || [] + }, + } + }, + + ContentAccess: { + model: ContentAccess, + isDefaultTypeForTable: true, + attributes: [ + 'id', + 'created_at', + 'updated_at', + 'user_id', + 'granted_by_group_id', + 'group_id', + 'track_id', + 'group_role_id', + 'common_role_id', + 'access_type', + 'stripe_session_id', + 'stripe_subscription_id', + 'status', + 'granted_by_id', + 'expires_at', + 'metadata' + ], + relations: [ + 'user', + 'grantedByGroup', + 'group', + { product: { alias: 'offering', typename: 'StripeOffering' } }, + 'track', + 'groupRole', + 'commonRole', + 'grantedBy' + ], + fetchMany: (args) => { + // Store args for use in filter function + ContentAccess._fetchManyArgs = args + return ContentAccess + }, + filter: (relation) => { + const args = ContentAccess._fetchManyArgs || {} + const { groupIds, search, accessType, status, offeringId, trackId, groupRoleId, commonRoleId, sortBy = 'created_at', order } = args + + return relation.query(q => { + // Filter by group IDs (groups that granted the access) + if (groupIds && groupIds.length > 0) { + q.whereIn('content_access.granted_by_group_id', groupIds) + } + + // Filter by user name search + if (search) { + q.join('users', 'users.id', '=', 'content_access.user_id') + q.whereRaw('users.name ilike ?', `%${search}%`) + } + + // Filter by access type + if (accessType) { + q.where('content_access.access_type', accessType) + } + + // Filter by status + if (status) { + q.where('content_access.status', status) + } + + // Filter by offering ID + if (offeringId) { + q.where('content_access.product_id', offeringId) + } + + // Filter by track ID + if (trackId) { + q.where('content_access.track_id', trackId) + } + + // Filter by group role ID + if (groupRoleId) { + q.where('content_access.group_role_id', groupRoleId) + } + + // Filter by common role ID + if (commonRoleId) { + q.where('content_access.common_role_id', commonRoleId) + } + + // Apply sorting + const validSortColumns = { + created_at: 'content_access.created_at', + expires_at: 'content_access.expires_at', + user_name: 'users.name' + } + + const sortColumn = validSortColumns[sortBy] || validSortColumns.created_at + + // If sorting by user name and not already joined, join users table + if (sortBy === 'user_name' && !search) { + q.join('users', 'users.id', '=', 'content_access.user_id') + } + + // Apply sorting + if (sortBy === 'user_name') { + q.orderByRaw(`lower("users"."name") ${order || 'asc'}`) + } else { + q.orderBy(sortColumn, order || 'desc') + } + }) + }, + getters: { + userId: ca => ca.get('user_id'), + grantedByGroupId: ca => ca.get('granted_by_group_id'), + groupId: ca => ca.get('group_id'), + offeringId: ca => ca.get('product_id'), + trackId: ca => ca.get('track_id'), + groupRoleId: ca => ca.get('group_role_id'), + commonRoleId: ca => ca.get('common_role_id'), + accessType: ca => ca.get('access_type'), + stripeSessionId: ca => ca.get('stripe_session_id'), + stripeSubscriptionId: ca => ca.get('stripe_subscription_id'), + grantedById: ca => ca.get('granted_by_id'), + subscriptionCancelAtPeriodEnd: ca => { + const metadata = ca.get('metadata') || {} + return metadata.subscription_cancel_at_period_end === true + }, + subscriptionPeriodEnd: ca => { + const metadata = ca.get('metadata') || {} + return metadata.subscription_period_end ? new Date(metadata.subscription_period_end) : null + }, + subscriptionCancellationScheduledAt: ca => { + const metadata = ca.get('metadata') || {} + return metadata.subscription_cancellation_scheduled_at ? new Date(metadata.subscription_cancellation_scheduled_at) : null + }, + subscriptionCancelReason: ca => { + const metadata = ca.get('metadata') || {} + return metadata.subscription_cancel_reason || null + } + } } } } diff --git a/apps/backend/api/graphql/makeSchema.js b/apps/backend/api/graphql/makeSchema.js index 5210957112..50937c45f1 100644 --- a/apps/backend/api/graphql/makeSchema.js +++ b/apps/backend/api/graphql/makeSchema.js @@ -72,6 +72,7 @@ import { findOrCreateThread, flagInappropriateContent, fulfillPost, + grantContentAccess, inviteGroupToGroup, invitePeerRelationship, invitePeopleToEvent, @@ -92,6 +93,7 @@ import { reactOn, reactivateUser, recordClickthrough, + recordStripePurchase, regenerateAccessCode, registerDevice, registerStripeAccount, @@ -111,8 +113,10 @@ import { removeSuggestedSkillFromGroup, reorderContextWidget, reorderPostInCollection, + refundContentAccess, resendInvitation, respondToEvent, + revokeContentAccess, savePost, sendEmailVerification, sendPasswordReset, @@ -145,8 +149,24 @@ import { updateStripeAccount, updateWidget, useInvitation, + createStripeConnectedAccount, + createStripeAccountLink, + createStripeOffering, + updateStripeOffering, + createStripeCheckoutSession, + checkStripeStatus, verifyEmail } from './mutations' +import { + stripeAccountStatus, + stripeOfferings, + publicStripeOfferings, + publicStripeOffering, + offeringSubscriptionStats, + offeringSubscribers, + checkContentAccess, + myTransactions +} from './queries' import peopleTyping from './mutations/peopleTyping' import InvitationService from '../services/InvitationService' import makeModels from './makeModels' @@ -315,12 +335,25 @@ export function makePublicQueries ({ fetchOne, fetchMany }) { return { checkInvitation: (root, { invitationToken, accessCode }) => InvitationService.check(invitationToken, accessCode), - // Can only access public communities and posts - group: async (root, { id, slug }) => fetchOne('Group', slug || id, slug ? 'slug' : 'id', { visibility: Group.Visibility.PUBLIC }), + // Can only access public communities and posts, unless a valid invitation is provided + group: async (root, { id, slug, accessCode, invitationToken }) => { + // If invitation credentials are provided, validate and bypass visibility filter + if (accessCode || invitationToken) { + const inviteCheck = await InvitationService.check(invitationToken, accessCode) + if (inviteCheck?.valid) { + // Fetch group without visibility restriction + return Group.where(slug ? { slug } : { id }).where({ active: true }).fetch() + } + } + // Default: only allow PUBLIC visibility groups + return fetchOne('Group', slug || id, slug ? 'slug' : 'id', { visibility: Group.Visibility.PUBLIC }) + }, groups: (root, args) => fetchMany('Group', Object.assign(args, { visibility: Group.Visibility.PUBLIC })), platformAgreements: (root, args) => PlatformAgreement.fetchAll(args), post: (root, { id }) => fetchOne('Post', id, 'id', { isPublic: true }), - posts: (root, args) => fetchMany('Post', Object.assign(args, { isPublic: true })) + posts: (root, args) => fetchMany('Post', Object.assign(args, { isPublic: true })), + publicStripeOfferings: (root, { groupId }) => publicStripeOfferings(null, { groupId }), + publicStripeOffering: (root, { offeringId }) => publicStripeOffering(null, { offeringId }) } } @@ -328,16 +361,29 @@ export function makePublicQueries ({ fetchOne, fetchMany }) { export function makeAuthenticatedQueries ({ fetchOne, fetchMany }) { return { activity: (root, { id }) => fetchOne('Activity', id), + checkContentAccess: (root, args, context) => checkContentAccess(context.currentUserId, args), checkInvitation: (root, { invitationToken, accessCode }) => InvitationService.check(invitationToken, accessCode), collection: (root, { id }) => fetchOne('Collection', id), comment: (root, { id }) => fetchOne('Comment', id), commonRoles: (root, args) => CommonRole.fetchAll(args), connections: (root, args) => fetchMany('PersonConnection', args), + contentAccess: (root, args) => fetchMany('ContentAccess', args), fundingRound: (root, { id }) => fetchOne('FundingRound', id), - group: async (root, { id, slug, updateLastViewed }, context) => { - // you can specify id or slug, but not both - const group = await fetchOne('Group', slug || id, slug ? 'slug' : 'id') + group: async (root, { id, slug, updateLastViewed, accessCode, invitationToken }, context) => { + let group + // If invitation credentials are provided, validate and bypass visibility filter + if (accessCode || invitationToken) { + const inviteCheck = await InvitationService.check(invitationToken, accessCode) + if (inviteCheck?.valid) { + // Fetch group directly without normal visibility filter + group = await Group.where(slug ? { slug } : { id }).where({ active: true }).fetch() + } + } + // Default: use normal fetch with group filter applied + if (!group) { + group = await fetchOne('Group', slug || id, slug ? 'slug' : 'id') + } if (updateLastViewed && group) { // Resets new post count to 0 await GroupMembership.updateLastViewedAt(context.currentUserId, group) @@ -362,6 +408,7 @@ export function makeAuthenticatedQueries ({ fetchOne, fetchMany }) { groups: (root, args) => fetchMany('Group', args), joinRequests: (root, args) => fetchMany('JoinRequest', args), me: (root, args, context) => fetchOne('Me', context.currentUserId), + myTransactions: (root, args, context) => myTransactions(context.currentUserId, args), messageThread: (root, { id }) => fetchOne('MessageThread', id), moderationActions: (root, args) => fetchMany('ModerationAction', args), notifications: async (root, { first, offset, resetCount, order = 'desc' }, context) => { @@ -388,6 +435,12 @@ export function makeAuthenticatedQueries ({ fetchOne, fetchMany }) { }) }, skills: (root, args) => fetchMany('Skill', args), + stripeAccountStatus: (root, { groupId, accountId }, context) => stripeAccountStatus(context.currentUserId, { groupId, accountId }), + stripeOfferings: (root, { groupId, accountId }, context) => stripeOfferings(context.currentUserId, { groupId, accountId }), + publicStripeOfferings: (root, { groupId }) => publicStripeOfferings(null, { groupId }), + publicStripeOffering: (root, { offeringId }) => publicStripeOffering(null, { offeringId }), + offeringSubscriptionStats: (root, { offeringId, groupId }, context) => offeringSubscriptionStats(context.currentUserId, { offeringId, groupId }), + offeringSubscribers: (root, { offeringId, groupId, page, pageSize, lapsedOnly }, context) => offeringSubscribers(context.currentUserId, { offeringId, groupId, page, pageSize, lapsedOnly }), // you can specify id or name, but not both topic: (root, { id, name }) => fetchOne('Topic', name || id, name ? 'name' : 'id'), topicFollow: (root, { groupId, topicName }, context) => TagFollow.findOrCreate({ groupId, topicName, userId: context.currentUserId }), @@ -403,7 +456,8 @@ export function makePublicMutations ({ fetchOne }) { sendEmailVerification, sendPasswordReset, register: register(fetchOne), - verifyEmail: verifyEmail(fetchOne) + verifyEmail: verifyEmail(fetchOne), + createStripeCheckoutSession: (root, { groupId, offeringId, quantity, successUrl, cancelUrl, metadata }) => createStripeCheckoutSession(null, { groupId, offeringId, quantity, successUrl, cancelUrl, metadata }) } } @@ -453,6 +507,14 @@ export function makeMutations ({ fetchOne }) { completePost: (root, { postId, completionResponse }, context) => completePost(context.currentUserId, postId, completionResponse), + grantContentAccess: (root, args, context) => grantContentAccess(context.currentUserId, args), + + revokeContentAccess: (root, args, context) => revokeContentAccess(context.currentUserId, args), + + refundContentAccess: (root, args, context) => refundContentAccess(context.currentUserId, args), + + recordStripePurchase: (root, args, context) => recordStripePurchase(context.currentUserId, args), + createAffiliation: (root, { data }, context) => createAffiliation(context.currentUserId, data), createCollection: (root, { data }, context) => createCollection(context.currentUserId, data), @@ -547,7 +609,7 @@ export function makeMutations ({ fetchOne }) { joinFundingRound: (root, { id }, context) => joinFundingRound(context.currentUserId, id), - joinGroup: (root, { groupId, questionAnswers }, context) => joinGroup(groupId, context.currentUserId, questionAnswers, context), + joinGroup: (root, { groupId, questionAnswers, accessCode, invitationToken, acceptAgreements }, context) => joinGroup(groupId, context.currentUserId, questionAnswers, accessCode, invitationToken, acceptAgreements, context), joinProject: (root, { id }, context) => joinProject(id, context.currentUserId), @@ -586,6 +648,18 @@ export function makeMutations ({ fetchOne }) { registerStripeAccount: (root, { authorizationCode }, context) => registerStripeAccount(context.currentUserId, authorizationCode), + createStripeConnectedAccount: (root, { groupId, email, businessName, country, existingAccountId }, context) => createStripeConnectedAccount(context.currentUserId, { groupId, email, businessName, country, existingAccountId }), + + createStripeAccountLink: (root, { groupId, accountId, returnUrl, refreshUrl }, context) => createStripeAccountLink(context.currentUserId, { groupId, accountId, returnUrl, refreshUrl }), + + createStripeOffering: (root, { input }, context) => createStripeOffering(context.currentUserId, input), + + updateStripeOffering: (root, { offeringId, name, description, priceInCents, currency, contentAccess, renewalPolicy, duration, publishStatus }, context) => updateStripeOffering(context.currentUserId, { offeringId, name, description, priceInCents, currency, contentAccess, renewalPolicy, duration, publishStatus }), + + createStripeCheckoutSession: (root, { groupId, offeringId, quantity, successUrl, cancelUrl, metadata }, context) => createStripeCheckoutSession(context.currentUserId, { groupId, offeringId, quantity, successUrl, cancelUrl, metadata }), + + checkStripeStatus: (root, { groupId }, context) => checkStripeStatus(context.currentUserId, { groupId }), + reinviteAll: (root, { groupId }, context) => reinviteAll(context.currentUserId, groupId), rejectGroupRelationshipInvite: (root, { groupRelationshipInviteId }, context) => rejectGroupRelationshipInvite(context.currentUserId, groupRelationshipInviteId), diff --git a/apps/backend/api/graphql/mutations/contentAccess.js b/apps/backend/api/graphql/mutations/contentAccess.js new file mode 100644 index 0000000000..c04d8f337c --- /dev/null +++ b/apps/backend/api/graphql/mutations/contentAccess.js @@ -0,0 +1,619 @@ +/** + * Content Access GraphQL Mutations + * + * Provides GraphQL API for managing content access grants: + * - Admin-granted free access to content + * - Recording Stripe purchases + * - Revoking access + * - Refunding purchases + */ + +import { GraphQLError } from 'graphql' +const StripeService = require('../../services/StripeService') + +/* global ContentAccess, GroupMembership, User, Group, Responsibility, Track, StripeProduct, GroupRole, Frontend, StripeAccount */ + +module.exports = { + + /** + * Grant free access to content (admin only) + * + * Allows group administrators to grant users free access to paid content + * without requiring a Stripe purchase. Useful for comps, staff access, + * promotional access, etc. + * + * Usage: + * mutation { + * grantContentAccess( + * userId: "456" + * grantedByGroupId: "123" + * groupId: "789" // optional - for access to a group + * productId: "789" // optional - for product-based access + * trackId: "101" // optional - for track-based access + * expiresAt: "2025-12-31T23:59:59Z" // optional + * reason: "Staff member" + * ) { + * id + * success + * message + * } + * } + */ + grantContentAccess: async (sessionUserId, { + userId, + grantedByGroupId, + groupId, + productId, + trackId, + groupRoleId, + commonRoleId, + expiresAt, + reason + }) => { + try { + // Check if user is authenticated + if (!sessionUserId) { + throw new GraphQLError('You must be logged in to grant content access') + } + + // Verify the granting group exists first + const grantingGroup = await Group.where({ id: grantedByGroupId }).fetch() + if (!grantingGroup) { + throw new GraphQLError('Granting group not found') + } + + // Verify user has admin permission for the granting group + const hasAdmin = await GroupMembership.hasResponsibility(sessionUserId, grantedByGroupId, Responsibility.constants.RESP_ADMINISTRATION) + if (!hasAdmin) { + throw new GraphQLError('You must be an administrator of the granting group to grant content access') + } + + // Verify the target user exists + const targetUser = await User.where({ id: userId }).fetch() + if (!targetUser) { + throw new GraphQLError('User not found') + } + + // If groupId is provided, verify it exists + if (groupId) { + const targetGroup = await Group.where({ id: groupId }).fetch() + if (!targetGroup) { + throw new GraphQLError('Target group not found') + } + } + + // Must provide either groupId, productId, trackId, groupRoleId, or commonRoleId + if (!groupId && !productId && !trackId && !groupRoleId && !commonRoleId) { + throw new GraphQLError('Must specify either groupId, productId, trackId, groupRoleId, or commonRoleId') + } + + // Grant access using the ContentAccess model + const access = await ContentAccess.grantAccess({ + userId, + grantedByGroupId, + groupId, + grantedById: sessionUserId, + productId, + trackId, + groupRoleId, + commonRoleId, + expiresAt, + reason + }) + + // Auto-enroll user in track when access is granted + if (trackId) { + try { + await Track.enroll(trackId, userId) + } catch (enrollError) { + // Log but don't fail the access grant if enrollment fails + console.warn(`Auto-enrollment in track ${trackId} failed for user ${userId}:`, enrollError.message) + } + } + + if (groupId) { + try { + await GroupMembership.ensureMembership(userId, groupId, { + role: GroupMembership.Role.DEFAULT + }) + + if (process.env.NODE_ENV === 'development') { + console.log(`Created group membership for user ${userId} in group ${groupId} via admin grant`) + } + } catch (membershipError) { + // Log but don't fail the access grant if membership creation fails + console.warn(`Group membership creation failed for user ${userId} in group ${groupId}:`, membershipError.message) + } + } + + // Send Admin-Granted Access email + try { + const userLocale = targetUser.getLocale() + const grantedByUser = await User.find(sessionUserId) + + // Determine access type and gather data + let accessType = null + let accessName = null + let accessUrl = null + let contextGroup = null + let contextGroupName = null + let contextGroupUrl = null + + if (trackId) { + accessType = 'track' + const track = await Track.find(trackId) + if (track) { + accessName = track.get('name') + // Track access is always within a group context + const trackGroupId = track.get('group_id') + contextGroup = await Group.find(trackGroupId) + if (contextGroup) { + contextGroupName = contextGroup.get('name') + contextGroupUrl = Frontend.Route.group(contextGroup) + accessUrl = Frontend.Route.track(track, contextGroup) + } + } + } else if (groupRoleId) { + accessType = 'group_role' + const roleIdToUse = groupRoleId + const role = await GroupRole.where({ id: roleIdToUse }).fetch() + if (role) { + accessName = role.get('name') + // Role access is within a group context + const roleGroupId = role.get('group_id') + contextGroup = await Group.find(roleGroupId) + if (contextGroup) { + contextGroupName = contextGroup.get('name') + contextGroupUrl = Frontend.Route.group(contextGroup) + accessUrl = contextGroupUrl + } + } + } else if (commonRoleId) { + accessType = 'common_role' + /* global CommonRole */ + const role = await CommonRole.where({ id: commonRoleId }).fetch() + if (role) { + accessName = role.get('name') + // Common role access is within a group context + contextGroup = await Group.find(grantedByGroupId) + if (contextGroup) { + contextGroupName = contextGroup.get('name') + contextGroupUrl = Frontend.Route.group(contextGroup) + accessUrl = contextGroupUrl + } + } + } else if (productId) { + accessType = 'offering' + const product = await StripeProduct.where({ id: productId }).fetch() + if (product) { + accessName = product.get('name') + // Product access is within a group context + const productGroupId = product.get('group_id') + contextGroup = await Group.find(productGroupId) + if (contextGroup) { + contextGroupName = contextGroup.get('name') + contextGroupUrl = Frontend.Route.group(contextGroup) + accessUrl = contextGroupUrl + } + } + } else if (groupId) { + accessType = 'group' + contextGroup = await Group.find(groupId) + if (contextGroup) { + accessName = contextGroup.get('name') + accessUrl = Frontend.Route.group(contextGroup) + contextGroupName = accessName + contextGroupUrl = accessUrl + } + } + + // Build email data + const emailData = { + user_name: targetUser.get('name'), + access_type: accessType, + access_name: accessName, + access_url: accessUrl, + granted_by_name: grantedByUser ? grantedByUser.get('name') : 'Administrator' + } + + // Add context group info if available + if (contextGroupName) { + emailData.group_name = contextGroupName + } + if (contextGroupUrl) { + emailData.group_url = contextGroupUrl + } + if (contextGroup) { + emailData.group_avatar_url = contextGroup.get('avatar_url') + } + + // Add expiration date if provided + if (expiresAt) { + const expiresAtDate = new Date(expiresAt) + emailData.expires_at = expiresAtDate.toLocaleDateString(userLocale === 'es' ? 'es-ES' : 'en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }) + } + + // Queue the email + Queue.classMethod('Email', 'sendAccessGranted', { + email: targetUser.get('email'), + data: emailData, + version: 'Redesign 2025', + locale: userLocale + }) + + if (process.env.NODE_ENV === 'development') { + console.log(`Queued Admin-Granted Access email to user ${userId}`) + } + } catch (emailError) { + // Log error but don't fail the access grant if email fails + console.error('Error queueing admin-granted access email:', emailError) + } + + return { + id: access.id, + userId, + grantedByGroupId, + groupId, + productId, + trackId, + groupRoleId: access.get('group_role_id'), + commonRoleId: access.get('common_role_id'), + accessType: access.get('access_type'), + status: access.get('status'), + success: true, + message: 'Access granted successfully' + } + } catch (error) { + if (error instanceof GraphQLError) { + throw error + } + console.error('Error in grantContentAccess:', error) + throw new GraphQLError(`Failed to grant access: ${error.message}`) + } + }, + + /** + * Revoke content access (admin only) + * + * Allows group administrators to revoke previously granted access. + * Can be used for both purchased and admin-granted access. + * + * Usage: + * mutation { + * revokeContentAccess( + * accessId: "123" + * reason: "User violated terms" + * ) { + * id + * status + * } + * } + */ + revokeContentAccess: async (sessionUserId, { accessId, reason }) => { + try { + // Check if user is authenticated + if (!sessionUserId) { + throw new GraphQLError('You must be logged in to revoke content access') + } + + // Load the access record and verify permissions + const access = await ContentAccess.where({ id: accessId }).fetch() + if (!access) { + throw new GraphQLError('Access record not found') + } + + // Check permissions against the granting group + const hasAdmin = await GroupMembership.hasResponsibility(sessionUserId, access.get('granted_by_group_id'), Responsibility.constants.RESP_ADMINISTRATION) + if (!hasAdmin) { + throw new GraphQLError('You must be an administrator of the granting group to revoke access') + } + + // Revoke the access using the model method - returns the updated record + const revokedAccess = await ContentAccess.revoke(accessId, sessionUserId, reason) + + return revokedAccess + } catch (error) { + if (error instanceof GraphQLError) { + throw error + } + console.error('Error in revokeContentAccess:', error) + throw new GraphQLError(`Failed to revoke access: ${error.message}`) + } + }, + + /** + * Record a Stripe purchase + * + * Internal mutation to record successful Stripe purchases. + * This should be called from the Stripe webhook handler after + * a successful checkout.session.completed event. + * + * Usage (internal): + * mutation { + * recordStripePurchase( + * userId: "456" + * grantedByGroupId: "123" + * groupId: "789" // optional + * productId: "789" + * trackId: "101" // optional + * groupRoleId: "202" // optional + * commonRoleId: "203" // optional + * sessionId: "cs_xxx" + * stripeSubscriptionId: "sub_xxx" // optional, for recurring + * ) { + * id + * success + * } + * } + */ + recordStripePurchase: async (sessionUserId, { + userId, + grantedByGroupId, + groupId, + productId, + trackId, + groupRoleId, + commonRoleId, + sessionId, + stripeSubscriptionId, + amountPaid, + currency, + expiresAt, + metadata + }) => { + try { + // This mutation should ideally only be called internally from webhook handler + // For security, you might want to add special authentication for this + + // Record the purchase using the model method + const access = await ContentAccess.recordPurchase({ + userId, + grantedByGroupId, + groupId, + productId, + trackId, + groupRoleId, + commonRoleId, + sessionId, + stripeSubscriptionId, + amountPaid, + currency, + expiresAt, + metadata: metadata || {} + }) + + return { + id: access.id, + success: true, + message: 'Purchase recorded successfully' + } + } catch (error) { + if (error instanceof GraphQLError) { + throw error + } + console.error('Error in recordStripePurchase:', error) + throw new GraphQLError(`Failed to record purchase: ${error.message}`) + } + }, + + /** + * Refund content access (admin only) + * + * Revokes access, cancels any associated subscription, and issues a Stripe refund + * for the most recent payment. This is a destructive action that cannot be undone. + * + * Usage: + * mutation { + * refundContentAccess( + * accessId: "123" + * reason: "Customer requested refund" + * ) { + * id + * status + * metadata + * } + * } + */ + refundContentAccess: async (sessionUserId, { accessId, reason }) => { + try { + // Check if user is authenticated + if (!sessionUserId) { + throw new GraphQLError('You must be logged in to refund content access') + } + + // Load the access record with related data + const access = await ContentAccess.where({ id: accessId }).fetch({ + withRelated: ['grantedByGroup'] + }) + if (!access) { + throw new GraphQLError('Access record not found') + } + + // Check permissions against the granting group + const hasAdmin = await GroupMembership.hasResponsibility( + sessionUserId, + access.get('granted_by_group_id'), + Responsibility.constants.RESP_ADMINISTRATION + ) + if (!hasAdmin) { + throw new GraphQLError('You must be an administrator of the granting group to refund access') + } + + // Verify this is a Stripe purchase (not an admin grant) + if (access.get('access_type') !== ContentAccess.Type.STRIPE_PURCHASE) { + throw new GraphQLError('Only Stripe purchases can be refunded. Admin grants should be revoked instead.') + } + + // Get the Stripe account info + const grantedByGroup = access.related('grantedByGroup') + const stripeAccountId = grantedByGroup.get('stripe_account_id') + if (!stripeAccountId) { + throw new GraphQLError('No Stripe account found for the granting group') + } + + const stripeAccount = await StripeAccount.where({ id: stripeAccountId }).fetch() + if (!stripeAccount) { + throw new GraphQLError('Stripe account record not found') + } + const externalAccountId = stripeAccount.get('stripe_account_external_id') + + // Issue the refund + const subscriptionId = access.get('stripe_subscription_id') + const sessionId = access.get('stripe_session_id') + let refund = null + + if (process.env.NODE_ENV === 'development') { + console.log(`[RefundContentAccess] subscriptionId: ${subscriptionId}, sessionId: ${sessionId}, accountId: ${externalAccountId}`) + } + + if (subscriptionId) { + // For subscriptions, StripeService.refund handles finding the payment: + // 1. First tries listing paid invoices + // 2. Falls back to subscription.latest_invoice.payment_intent + // 3. Falls back to listing all invoices + try { + refund = await StripeService.refund({ + accountId: externalAccountId, + subscriptionId, + reason: 'requested_by_customer' + }) + } catch (subscriptionRefundError) { + // If subscription-based refund fails but we have a session ID, try that + if (process.env.NODE_ENV === 'development') { + console.log(`[RefundContentAccess] Subscription refund failed: ${subscriptionRefundError.message}`) + } + if (sessionId) { + if (process.env.NODE_ENV === 'development') { + console.log('[RefundContentAccess] Trying checkout session as fallback...') + } + const session = await StripeService.getCheckoutSession(externalAccountId, sessionId) + if (process.env.NODE_ENV === 'development') { + console.log(`[RefundContentAccess] Session mode: ${session.mode}, payment_intent: ${session.payment_intent}, invoice: ${session.invoice}`) + } + + // For subscription sessions, try the invoice first + if (session.invoice) { + const invoiceId = typeof session.invoice === 'string' ? session.invoice : session.invoice.id + if (process.env.NODE_ENV === 'development') { + console.log(`[RefundContentAccess] Fetching invoice: ${invoiceId}`) + } + const invoice = await StripeService.getInvoice(externalAccountId, invoiceId) + if (process.env.NODE_ENV === 'development') { + console.log(`[RefundContentAccess] Invoice: amount_due=${invoice.amount_due}, amount_paid=${invoice.amount_paid}, total=${invoice.total}, status=${invoice.status}`) + console.log(`[RefundContentAccess] Invoice payment_intent: ${invoice.payment_intent?.id || invoice.payment_intent}, charge: ${invoice.charge?.id || invoice.charge}`) + } + + // If it's a $0 invoice, we can't refund + if (invoice.amount_due === 0 || invoice.total === 0) { + throw new Error('This subscription had a $0 invoice - no payment was made, so there is nothing to refund.') + } + + if (invoice.payment_intent) { + const paymentIntentId = typeof invoice.payment_intent === 'string' + ? invoice.payment_intent + : invoice.payment_intent.id + refund = await StripeService.refund({ + accountId: externalAccountId, + paymentIntentId, + reason: 'requested_by_customer' + }) + } else if (invoice.charge) { + const chargeId = typeof invoice.charge === 'string' + ? invoice.charge + : invoice.charge.id + refund = await StripeService.refund({ + accountId: externalAccountId, + chargeId, + reason: 'requested_by_customer' + }) + } + } + + // If still no refund, try payment_intent (though this is usually null for subscriptions) + if (!refund && session.payment_intent) { + const paymentIntentId = typeof session.payment_intent === 'string' + ? session.payment_intent + : session.payment_intent.id + refund = await StripeService.refund({ + accountId: externalAccountId, + paymentIntentId, + reason: 'requested_by_customer' + }) + } + + if (!refund) { + throw subscriptionRefundError + } + } else { + throw subscriptionRefundError + } + } + } else if (sessionId) { + // For one-time payments, get the payment intent from the session + const session = await StripeService.getCheckoutSession(externalAccountId, sessionId) + if (session.payment_intent) { + const paymentIntentId = typeof session.payment_intent === 'string' + ? session.payment_intent + : session.payment_intent.id + refund = await StripeService.refund({ + accountId: externalAccountId, + paymentIntentId, + reason: 'requested_by_customer' + }) + } + } + + if (!refund) { + throw new GraphQLError('Unable to issue refund: no payment found for this access record') + } + + // IMPORTANT: Set status to REFUNDED *before* cancelling subscription + // This prevents the subscription.deleted webhook from overwriting to 'expired' + const metadata = access.get('metadata') || {} + metadata.refundId = refund.id + metadata.refundAmount = refund.amount + metadata.refundedAt = new Date().toISOString() + metadata.refundedBy = sessionUserId + metadata.revokedAt = new Date().toISOString() + metadata.revokedBy = sessionUserId + if (reason) metadata.refundReason = reason + + // Save REFUNDED status first + await access.save({ + status: ContentAccess.Status.REFUNDED, + metadata + }, { patch: true }) + + // Now cancel the subscription (if any) - webhook will see REFUNDED status and skip + if (subscriptionId) { + try { + await StripeService.cancelSubscription({ + accountId: externalAccountId, + subscriptionId, + immediately: true + }) + // Update metadata to note subscription was cancelled + metadata.subscriptionCancelled = true + await access.save({ metadata }, { patch: true }) + } catch (cancelError) { + console.error(`Failed to cancel subscription ${subscriptionId}:`, cancelError.message) + // Don't fail the refund if subscription cancellation fails + } + } + + // Refresh the access record to return current state + await access.refresh() + return access + } catch (error) { + if (error instanceof GraphQLError) { + throw error + } + console.error('Error in refundContentAccess:', error) + throw new GraphQLError(`Failed to refund access: ${error.message}`) + } + } +} diff --git a/apps/backend/api/graphql/mutations/contentAccess.test.js b/apps/backend/api/graphql/mutations/contentAccess.test.js new file mode 100644 index 0000000000..b12b9eb205 --- /dev/null +++ b/apps/backend/api/graphql/mutations/contentAccess.test.js @@ -0,0 +1,285 @@ +/* eslint-disable no-unused-expressions */ +import setup from '../../../test/setup' +import factories from '../../../test/setup/factories' +import { + grantContentAccess, + revokeContentAccess, + recordStripePurchase +} from './contentAccess' +const { expect } = require('chai') + +/* global ContentAccess, GroupMembership, StripeProduct, Track, GroupRole */ + +describe('Content Access Mutations', () => { + let user, adminUser, group, product, track + + before(async () => { + // Create test entities + user = await factories.user().save() + adminUser = await factories.user().save() + group = await factories.group().save() + product = await StripeProduct.forge({ + group_id: group.id, + stripe_product_id: 'prod_test_123', + stripe_price_id: 'price_test_123', + name: 'Test Product', + description: 'Test Description', + price_in_cents: 1000, + currency: 'usd', + publish_status: 'published' + }).save() + track = await Track.forge({ + name: 'Test Track', + description: 'Test Track Description' + }).save() + await group.tracks().attach(track.id) + + // Add admin user as group administrator + await adminUser.joinGroup(group, { role: GroupMembership.Role.MODERATOR }) + // Add regular user as group member + await user.joinGroup(group) + }) + + after(() => setup.clearDb()) + + describe('grantContentAccess', () => { + it('grants access to a product for a user', async () => { + const result = await grantContentAccess(adminUser.id, { + userId: user.id, + grantedByGroupId: group.id, + productId: product.id, + reason: 'Staff member' + }) + + expect(result.success).to.be.true + expect(result.userId).to.equal(user.id) + expect(result.grantedByGroupId).to.equal(group.id) + expect(result.productId).to.equal(product.id) + expect(result.accessType).to.equal('admin_grant') + expect(result.status).to.equal('active') + + // Verify the access record was created + const access = await ContentAccess.where({ id: result.id }).fetch() + expect(access).to.exist + expect(access.get('user_id')).to.equal(user.id) + expect(access.get('granted_by_group_id')).to.equal(group.id) + expect(access.get('product_id')).to.equal(product.id) + expect(access.get('access_type')).to.equal('admin_grant') + expect(access.get('status')).to.equal('active') + }) + + it('grants access to a track for a user', async () => { + const result = await grantContentAccess(adminUser.id, { + userId: user.id, + grantedByGroupId: group.id, + trackId: track.id, + reason: 'Promotional access' + }) + + expect(result.success).to.be.true + expect(result.trackId).to.equal(track.id) + + // Verify the access record was created + const access = await ContentAccess.where({ id: result.id }).fetch() + expect(access.get('track_id')).to.equal(track.id) + }) + + it('grants access with expiration date', async () => { + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days from now + + const result = await grantContentAccess(adminUser.id, { + userId: user.id, + grantedByGroupId: group.id, + productId: product.id, + expiresAt, + reason: 'Temporary access' + }) + + expect(result.success).to.be.true + + const access = await ContentAccess.where({ id: result.id }).fetch() + expect(access.get('expires_at')).to.be.closeToTime(expiresAt, 1000) + }) + + it('rejects access grant for non-authenticated users', async () => { + await expect( + grantContentAccess(null, { + userId: user.id, + grantedByGroupId: group.id, + productId: product.id, + reason: 'Test' + }) + ).to.be.rejectedWith('You must be logged in to grant content access') + }) + + it('rejects access grant for non-admin users', async () => { + await expect( + grantContentAccess(user.id, { + userId: user.id, + grantedByGroupId: group.id, + productId: product.id, + reason: 'Test' + }) + ).to.be.rejectedWith('You must be an administrator of the granting group to grant content access') + }) + + it('rejects access grant for non-existent user', async () => { + await expect( + grantContentAccess(adminUser.id, { + userId: 99999, + grantedByGroupId: group.id, + productId: product.id, + reason: 'Test' + }) + ).to.be.rejectedWith('User not found') + }) + + it('rejects access grant for non-existent group', async () => { + await expect( + grantContentAccess(adminUser.id, { + userId: user.id, + grantedByGroupId: 99999, + productId: product.id, + reason: 'Test' + }) + ).to.be.rejectedWith('Granting group not found') + }) + + it('rejects access grant without productId or trackId', async () => { + await expect( + grantContentAccess(adminUser.id, { + userId: user.id, + grantedByGroupId: group.id, + reason: 'Test' + }) + ).to.be.rejectedWith('Must specify either groupId, productId, trackId, groupRoleId, or commonRoleId') + }) + }) + + describe('revokeContentAccess', () => { + let accessRecord + + beforeEach(async () => { + // Create an access record to revoke + accessRecord = await ContentAccess.create({ + user_id: user.id, + granted_by_group_id: group.id, + product_id: product.id, + access_type: 'admin_grant', + status: 'active', + metadata: { reason: 'Test access' } + }) + }) + + it('revokes access for admin users', async () => { + const result = await revokeContentAccess(adminUser.id, { + accessId: accessRecord.id, + reason: 'Access no longer needed' + }) + + expect(result.success).to.be.true + expect(result.message).to.equal('Access revoked successfully') + + // Verify the access record was revoked + await accessRecord.refresh() + expect(accessRecord.get('status')).to.equal('revoked') + }) + + it('rejects revocation for non-authenticated users', async () => { + await expect( + revokeContentAccess(null, { + accessId: accessRecord.id, + reason: 'Test' + }) + ).to.be.rejectedWith('You must be logged in to revoke content access') + }) + + it('rejects revocation for non-admin users', async () => { + await expect( + revokeContentAccess(user.id, { + accessId: accessRecord.id, + reason: 'Test' + }) + ).to.be.rejectedWith('You must be an administrator of the granting group to revoke access') + }) + + it('rejects revocation for non-existent access record', async () => { + await expect( + revokeContentAccess(adminUser.id, { + accessId: 99999, + reason: 'Test' + }) + ).to.be.rejectedWith('Access record not found') + }) + }) + + describe('recordStripePurchase', () => { + it('records a successful Stripe purchase', async () => { + const sessionId = 'cs_test_123' + const expiresAt = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // 1 year from now + + const result = await recordStripePurchase(adminUser.id, { + userId: user.id, + grantedByGroupId: group.id, + productId: product.id, + sessionId, + expiresAt, + metadata: { source: 'webhook' } + }) + + expect(result.success).to.be.true + expect(result.message).to.equal('Purchase recorded successfully') + + // Verify the access record was created + const access = await ContentAccess.where({ id: result.id }).fetch() + expect(access.get('user_id')).to.equal(user.id) + expect(access.get('granted_by_group_id')).to.equal(group.id) + expect(access.get('product_id')).to.equal(product.id) + expect(access.get('access_type')).to.equal('stripe_purchase') + expect(access.get('stripe_session_id')).to.equal(sessionId) + expect(access.get('expires_at')).to.be.closeToTime(expiresAt, 1000) + expect(access.get('metadata')).to.deep.include({ source: 'webhook' }) + }) + + it('records a track-specific purchase', async () => { + const result = await recordStripePurchase(adminUser.id, { + userId: user.id, + grantedByGroupId: group.id, + trackId: track.id, + sessionId: 'cs_test_456', + metadata: { source: 'webhook' } + }) + + expect(result.success).to.be.true + + const access = await ContentAccess.where({ id: result.id }).fetch() + expect(access.get('track_id')).to.equal(track.id) + expect(access.get('access_type')).to.equal('stripe_purchase') + }) + + it('records a role-specific purchase', async () => { + // Create a role for this group first + const role = await GroupRole.forge({ + group_id: group.id, + name: 'Test Role', + emoji: '👤', + color: '#FF0000', + active: true + }).save() + + const result = await recordStripePurchase(adminUser.id, { + userId: user.id, + grantedByGroupId: group.id, + groupRoleId: role.id, + sessionId: 'cs_test_789', + metadata: { source: 'webhook' } + }) + + expect(result.success).to.be.true + + const access = await ContentAccess.where({ id: result.id }).fetch() + expect(access.get('group_role_id')).to.equal(role.id) + expect(access.get('access_type')).to.equal('stripe_purchase') + }) + }) +}) diff --git a/apps/backend/api/graphql/mutations/group.js b/apps/backend/api/graphql/mutations/group.js index 7771f43537..e5668950e8 100644 --- a/apps/backend/api/graphql/mutations/group.js +++ b/apps/backend/api/graphql/mutations/group.js @@ -1,5 +1,6 @@ import { GraphQLError } from 'graphql' import GroupService from '../../services/GroupService' +import InvitationService from '../../services/InvitationService' import convertGraphqlData from './convertGraphqlData' import underlyingDeleteGroupTopic from '../../models/group/deleteGroupTopic' import { @@ -91,16 +92,39 @@ export async function deleteGroupRelationship (userId, parentId, childId, contex throw new GraphQLError("You don't have permission to do this") } -// Called when a user joins an open group -export async function joinGroup (groupId, userId, questionAnswers, context) { +/** + * Join a group. For Open groups, anyone can join directly. + * For Restricted groups, a valid accessCode or invitationToken is required (pre-approved). + * @param groupId {string} the group to join + * @param userId {string} the user joining + * @param questionAnswers {array} answers to join questions + * @param accessCode {string} optional access code for pre-approved join + * @param invitationToken {string} optional invitation token for pre-approved join + * @param acceptAgreements {boolean} if true, record that user has accepted group agreements + * @param context {object} GraphQL context + */ +export async function joinGroup (groupId, userId, questionAnswers, accessCode, invitationToken, acceptAgreements, context) { const user = await User.find(userId) if (!user) throw new GraphQLError(`User id ${userId} not found`) const group = await Group.find(groupId) if (!group) throw new GraphQLError(`Group id ${groupId} not found`) - // TODO: what about hidden groups? can you join them? for now we are going with yes if not closed - if (group.get('accessibility') !== Group.Accessibility.OPEN) { - throw new GraphQLError('You do not have permisson to do that') + + // Check if user has a valid invitation for pre-approved join + let hasValidInvitation = false + if (accessCode || invitationToken) { + const inviteCheck = await InvitationService.check(invitationToken, accessCode) + // Validate the invitation is for this specific group + if (inviteCheck?.valid && inviteCheck?.groupSlug === group.get('slug')) { + hasValidInvitation = true + } } + + // For non-Open groups, require a valid invitation + if (group.get('accessibility') !== Group.Accessibility.OPEN && !hasValidInvitation) { + throw new GraphQLError('You do not have permission to do that') + } + + // TODO STRIPE: We need to think through how this joinGroup mutation will be impacted by paywall // Make sure user is first a member of all prerequisite groups const prerequisiteGroups = await group.prerequisiteGroups().fetch() await Promise.map(prerequisiteGroups.models, async (prereq) => { @@ -110,11 +134,16 @@ export async function joinGroup (groupId, userId, questionAnswers, context) { } }) - const result = await user.joinGroup(group, { questionAnswers }) + const membership = await user.joinGroup(group, { questionAnswers, fromInvitation: hasValidInvitation }) + + // Record agreement acceptance if user accepted agreements during join flow + if (acceptAgreements && membership) { + await membership.acceptAgreements() + } // Subscription publishing for group joins is handled in the background job Group.afterAddMembers - return result + return membership } export async function regenerateAccessCode (userId, groupId) { diff --git a/apps/backend/api/graphql/mutations/index.js b/apps/backend/api/graphql/mutations/index.js index e742b1d3ff..6ae7260c65 100644 --- a/apps/backend/api/graphql/mutations/index.js +++ b/apps/backend/api/graphql/mutations/index.js @@ -13,6 +13,12 @@ export { reorderPostInCollection, removePostFromCollection } from './collection' +export { + grantContentAccess, + revokeContentAccess, + refundContentAccess, + recordStripePurchase +} from './contentAccess' export { createComment, createMessage, @@ -159,6 +165,14 @@ export { createZapierTrigger, deleteZapierTrigger } from './zapier' +export { + createStripeConnectedAccount, + createStripeAccountLink, + createStripeOffering, + updateStripeOffering, + createStripeCheckoutSession, + checkStripeStatus +} from './stripe' export { default as findOrCreateThread } from '../../models/post/findOrCreateThread' export async function updateMe (sessionId, userId, changes) { diff --git a/apps/backend/api/graphql/mutations/invitation.js b/apps/backend/api/graphql/mutations/invitation.js index 5c4845f83a..c98722b497 100644 --- a/apps/backend/api/graphql/mutations/invitation.js +++ b/apps/backend/api/graphql/mutations/invitation.js @@ -22,6 +22,8 @@ export async function createInvitation (userId, groupId, data) { emails: data.emails, message: data.message, moderator: data.isModerator || false, + commonRoleId: data.commonRoleId ? parseInt(data.commonRoleId, 10) : null, + groupRoleId: data.groupRoleId ? parseInt(data.groupRoleId, 10) : null, subject: locales[locale].createInvitationSubject(group.get('name')) }) }) diff --git a/apps/backend/api/graphql/mutations/membership.js b/apps/backend/api/graphql/mutations/membership.js index ab9ea5bf1a..cdb9a2f7ce 100644 --- a/apps/backend/api/graphql/mutations/membership.js +++ b/apps/backend/api/graphql/mutations/membership.js @@ -27,7 +27,7 @@ export async function updateMembership (userId, { groupId, data, data: { setting const isNewPin = membership.get('nav_order') === null if (isNewPin) { - // Pinning a new group - increment all other pinned groups in a single query + // Pinning a new group to top (position 0) - increment all other pinned groups await bookshelf.knex('group_memberships') .where({ user_id: userId }) .whereNotNull('nav_order') diff --git a/apps/backend/api/graphql/mutations/project.js b/apps/backend/api/graphql/mutations/project.js index 8b83ca0cf7..aeb4b6ed65 100644 --- a/apps/backend/api/graphql/mutations/project.js +++ b/apps/backend/api/graphql/mutations/project.js @@ -2,7 +2,10 @@ import { GraphQLError } from 'graphql' import { uniq } from 'lodash/fp' import { createPost } from './post' -const stripe = require('stripe')(process.env.STRIPE_API_KEY) +const Stripe = require('stripe') +const stripe = process.env.STRIPE_SECRET_KEY + ? new Stripe(process.env.STRIPE_SECRET_KEY) + : null export function createProject (userId, data) { // add creator as a member of project on creation @@ -135,6 +138,10 @@ export async function createStripePaymentNotifications (contribution, creatorId) } export async function processStripeToken (userId, projectId, token, amount) { + if (!stripe) { + throw new GraphQLError('Stripe is not configured. Please set STRIPE_SECRET_KEY environment variable.') + } + const applicationFeeFraction = 0.01 const project = await Post.find(projectId) if (!project) { diff --git a/apps/backend/api/graphql/mutations/stripe.js b/apps/backend/api/graphql/mutations/stripe.js new file mode 100644 index 0000000000..d9bf319e1f --- /dev/null +++ b/apps/backend/api/graphql/mutations/stripe.js @@ -0,0 +1,617 @@ +/** + * Stripe Connect GraphQL Mutations + * + * Provides GraphQL API for Stripe Connect functionality: + * - Creating connected accounts for groups + * - Generating onboarding links + * - Managing offerings and prices + * - Creating checkout sessions + */ + +import { GraphQLError } from 'graphql' +import StripeService from '../../services/StripeService' + +/* global StripeProduct, Responsibility, Group, GroupMembership, StripeAccount */ + +/** + * Helper function to convert a database account ID to an external Stripe account ID + * If the accountId already starts with 'acct_', it's already the external ID + * Otherwise, look it up from the database + */ +async function getExternalAccountId (accountId) { + // If it already starts with 'acct_', it's already the external ID + if (accountId && accountId.startsWith('acct_')) { + return accountId + } + + // Otherwise, it's a database ID - look up the external account ID + const stripeAccount = await StripeAccount.where({ id: accountId }).fetch() + if (!stripeAccount) { + throw new GraphQLError('Stripe account record not found') + } + return stripeAccount.get('stripe_account_external_id') +} + +module.exports = { + + /** + * Creates a Stripe Connected Account for a group + */ + createStripeConnectedAccount: async (userId, { groupId, email, businessName, country }) => { + try { + // Check if user is authenticated + if (!userId) { + throw new GraphQLError('You must be logged in to create a connected account') + } + + // Load the group to verify it exists and user has permission + const group = await Group.find(groupId) + if (!group) { + throw new GraphQLError('Group not found') + } + + // Check if user is a steward/admin of the group + const hasAdmin = await GroupMembership.hasResponsibility(userId, groupId, Responsibility.constants.RESP_ADMINISTRATION) + if (!hasAdmin) { + throw new GraphQLError('You must be a group administrator to create a connected account') + } + + // Check if group already has a Stripe account + // If stripe_account_id exists, verify the database record still exists + // (in case DB was cleared but group still has the reference) + const existingStripeAccountId = group.get('stripe_account_id') + if (existingStripeAccountId) { + const existingStripeAccount = await StripeAccount.where({ id: existingStripeAccountId }).fetch() + if (existingStripeAccount) { + throw new GraphQLError('This group already has a Stripe account connected') + } + // If the database record doesn't exist, we can proceed to reconnect + // This handles the edge case where DB was cleared but Stripe account still exists + } + + // Create new Stripe account + // Note: Stripe will handle the case where the user already has an account + // by prompting them to connect it during onboarding + const account = await StripeService.createConnectedAccount({ + email: email || `${group.get('name')}@hylo.com`, + country: country || 'US', + businessName: businessName || group.get('name'), + groupId + }) + + // Find or create a StripeAccount record with this external ID + let stripeAccountRecord = await StripeAccount.where({ + stripe_account_external_id: account.id + }).fetch() + + if (!stripeAccountRecord) { + stripeAccountRecord = await StripeAccount.forge({ + stripe_account_external_id: account.id + }).save() + } + + // Save the database ID to the group + await group.save({ stripe_account_id: stripeAccountRecord.id }) + + return { + id: groupId, + accountId: account.id, + success: true, + message: 'Connected account created successfully' + } + } catch (error) { + // If it's already a GraphQLError, rethrow it as-is + if (error instanceof GraphQLError) { + throw error + } + console.error('Error in createStripeConnectedAccount:', error) + throw new GraphQLError(`Failed to create connected account: ${error.message}`) + } + }, + + /** + * Generates an Account Link for onboarding + * + * Account Links are temporary URLs that allow the connected account + * to complete onboarding and access the Stripe Dashboard. + * + * Usage: + * mutation { + * createStripeAccountLink( + * groupId: "123" + * accountId: "acct_xxx" + * returnUrl: "https://yourapp.com/groups/{groupSlug}/settings/paid-content" + * refreshUrl: "https://yourapp.com/groups/{groupSlug}/settings/paid-content" + * ) { + * url + * expiresAt + * } + * } + */ + createStripeAccountLink: async (userId, { groupId, accountId, returnUrl, refreshUrl }) => { + try { + // Check if user is authenticated + if (!userId) { + throw new GraphQLError('You must be logged in to create an account link') + } + + // Verify user has permission for this group + const hasAdmin = await GroupMembership.hasResponsibility(userId, groupId, Responsibility.constants.RESP_ADMINISTRATION) + if (!hasAdmin) { + throw new GraphQLError('You must be a group administrator to manage payments') + } + + // Convert database ID to external account ID if needed + const externalAccountId = await getExternalAccountId(accountId) + + // Create the account link + const accountLink = await StripeService.createAccountLink({ + accountId: externalAccountId, + returnUrl, + refreshUrl + }) + + return { + url: accountLink.url, + expiresAt: accountLink.expires_at, + success: true + } + } catch (error) { + if (error instanceof GraphQLError) { + throw error + } + console.error('Error in createStripeAccountLink:', error) + throw new GraphQLError(`Failed to create account link: ${error.message}`) + } + }, + + /** + * Creates an offering on the connected account + * + * Offerings represent subscription tiers, content access, or other + * offerings that the group wants to sell. + * + * Usage: + * mutation { + * createStripeOffering( + * groupId: "123" + * accountId: "acct_xxx" + * name: "Premium Membership" + * description: "Access to all premium content" + * priceInCents: 2000 + * currency: "usd" + * accessGrants: { + * trackIds: [456, 789] + * groupRoleIds: [1, 2] + * commonRoleIds: [3, 4] + * groupIds: [123] + * } + * publishStatus: "published" + * ) { + * productId + * priceId + * success + * } + * } + */ + createStripeOffering: async (userId, { + groupId, + accountId, + name, + description, + priceInCents, + currency, + accessGrants, + renewalPolicy, + duration, + publishStatus + }) => { + try { + // Check if user is authenticated + if (!userId) { + throw new GraphQLError('You must be logged in to create an offering') + } + + // Verify user has permission for this group + const hasAdmin = await GroupMembership.hasResponsibility(userId, groupId, Responsibility.constants.RESP_ADMINISTRATION) + if (!hasAdmin) { + throw new GraphQLError('You must be a group administrator to create offerings') + } + + // Convert database ID to external account ID if needed + const externalAccountId = await getExternalAccountId(accountId) + + // Determine billing interval based on duration for recurring products + // Map duration to Stripe interval and interval_count + let billingInterval = null + let billingIntervalCount = 1 + if (duration === 'day') { + billingInterval = 'day' + billingIntervalCount = 1 // Daily - for testing subscription expiration + } else if (duration === 'month') { + billingInterval = 'month' + billingIntervalCount = 1 + } else if (duration === 'season') { + billingInterval = 'month' + billingIntervalCount = 3 // Quarterly - every 3 months + } else if (duration === 'annual') { + billingInterval = 'year' + billingIntervalCount = 1 + } + // lifetime and null duration = one-time payment (no interval) + + // Create the product on the connected account + const product = await StripeService.createProduct({ + accountId: externalAccountId, + name, + description, + priceInCents, + currency: currency || 'usd', + billingInterval, + billingIntervalCount + }) + + // Save offering to database for tracking and association with content + // Default renewal_policy to 'automatic' for subscription-based products + const effectiveRenewalPolicy = renewalPolicy || (billingInterval ? 'automatic' : 'manual') + + const stripeProduct = await StripeProduct.create({ + group_id: groupId, + stripe_product_id: product.id, + stripe_price_id: product.default_price, + name: product.name, + description: product.description, + price_in_cents: priceInCents, + currency: currency || 'usd', + access_grants: accessGrants || {}, + renewal_policy: effectiveRenewalPolicy, + duration: duration || null, + publish_status: publishStatus || 'unpublished' + }) + + return { + productId: product.id, + priceId: product.default_price, + name: product.name, + databaseId: stripeProduct.id, + success: true, + message: 'Offering created successfully' + } + } catch (error) { + if (error instanceof GraphQLError) { + throw error + } + console.error('Error in createStripeOffering:', error) + throw new GraphQLError(`Failed to create offering: ${error.message}`) + } + }, + + /** + * Updates an existing Stripe offering + * + * Allows group administrators to update offering details including name, description, + * price, content access, renewal policy, duration, and publish status. + * + * Usage: + * mutation { + * updateStripeOffering( + * offeringId: "123" + * name: "Updated Premium Membership" + * description: "Updated description" + * priceInCents: 2500 + * accessGrants: { + * trackIds: [456, 789] + * groupRoleIds: [1, 2] + * commonRoleIds: [3, 4] + * groupIds: [123] + * } + * publishStatus: "published" + * ) { + * success + * message + * } + * } + */ + updateStripeOffering: async (userId, { + offeringId, + name, + description, + priceInCents, + currency, + accessGrants, + renewalPolicy, + duration, + publishStatus + }) => { + try { + // Check if user is authenticated + if (!userId) { + throw new GraphQLError('You must be logged in to update an offering') + } + + // Load the offering and verify permissions + const product = await StripeProduct.where({ id: offeringId }).fetch() + if (!product) { + throw new GraphQLError('Offering not found') + } + + const hasAdmin = await GroupMembership.hasResponsibility(userId, product.get('group_id'), Responsibility.constants.RESP_ADMINISTRATION) + if (!hasAdmin) { + throw new GraphQLError('You must be a group administrator to update offerings') + } + + // Prepare update attributes (only include provided fields) + const updateAttrs = {} + + // Fields that need to be synced with Stripe + const stripeSyncFields = {} + if (name !== undefined) { + updateAttrs.name = name + stripeSyncFields.name = name + } + if (description !== undefined) { + updateAttrs.description = description + stripeSyncFields.description = description + } + if (priceInCents !== undefined) { + updateAttrs.price_in_cents = priceInCents + stripeSyncFields.priceInCents = priceInCents + } + if (currency !== undefined) { + updateAttrs.currency = currency + stripeSyncFields.currency = currency + } + + // Fields that are platform-only (don't sync with Stripe) + if (accessGrants !== undefined) updateAttrs.access_grants = accessGrants + if (renewalPolicy !== undefined) updateAttrs.renewal_policy = renewalPolicy + if (duration !== undefined) updateAttrs.duration = duration + if (publishStatus !== undefined) { + // Validate publish status + const validStatuses = ['unpublished', 'unlisted', 'published', 'archived'] + if (!validStatuses.includes(publishStatus)) { + throw new GraphQLError('Invalid publish status. Must be unpublished, unlisted, published, or archived') + } + updateAttrs.publish_status = publishStatus + } + + // Sync with Stripe first if there are fields that need syncing + if (Object.keys(stripeSyncFields).length > 0) { + const group = await Group.find(product.get('group_id')) + if (!group || !group.get('stripe_account_id')) { + throw new GraphQLError('Group does not have a connected Stripe account') + } + + // If price is being updated but not currency, preserve the existing currency + if (priceInCents !== undefined && currency === undefined) { + stripeSyncFields.currency = product.get('currency') + } + + // Update product in Stripe first + const updatedStripeProduct = await StripeService.updateProduct({ + accountId: group.get('stripe_account_id'), + productId: product.get('stripe_product_id'), + ...stripeSyncFields + }) + + // Update our database with the actual values from Stripe + // Only update fields that were actually provided in the request + if (name !== undefined) updateAttrs.name = updatedStripeProduct.name + if (description !== undefined) updateAttrs.description = updatedStripeProduct.description + if (priceInCents !== undefined) updateAttrs.price_in_cents = updatedStripeProduct.default_price.unit_amount + if (currency !== undefined || (priceInCents !== undefined && currency === undefined)) { + // Update currency if explicitly provided, or if price changed and we preserved it + updateAttrs.currency = updatedStripeProduct.default_price.currency + } + // Always update the stripe_price_id if price changed + if (priceInCents !== undefined || currency !== undefined) { + updateAttrs.stripe_price_id = updatedStripeProduct.default_price.id + } + } + + // Update the offering in our database + await product.save(updateAttrs) + + return { + success: true, + message: 'Offering updated successfully' + } + } catch (error) { + if (error instanceof GraphQLError) { + throw error + } + console.error('Error in updateStripeOffering:', error) + throw new GraphQLError(`Failed to update offering: ${error.message}`) + } + }, + + /** + * Creates a checkout session for purchasing a product + * + * This mutation creates a Stripe Checkout session and returns a URL + * that the customer should be redirected to for payment. + * + * Usage: + * mutation { + * createStripeCheckoutSession( + * groupId: "123" + * accountId: "acct_xxx" + * priceId: "price_xxx" + * quantity: 1 + * successUrl: "https://yourapp.com/success" + * cancelUrl: "https://yourapp.com/cancel" + * ) { + * sessionId + * url + * } + * } + */ + createStripeCheckoutSession: async (userId, { + groupId, + offeringId, + quantity, + successUrl, + cancelUrl, + metadata + }) => { + try { + // Authentication is optional for checkout - you may want to allow guests + // For this demo, we'll allow unauthenticated purchases + + // Look up the offering from the database + const offering = await StripeProduct.where({ id: offeringId }).fetch() + if (!offering) { + throw new GraphQLError('Offering not found') + } + + // Verify the offering belongs to the specified group + const offeringGroupId = offering.get('group_id') + if (parseInt(offeringGroupId) !== parseInt(groupId)) { + throw new GraphQLError('Offering does not belong to the specified group') + } + + // Get the group to access its Stripe account ID + const group = await Group.where({ id: groupId }).fetch() + if (!group) { + throw new GraphQLError('Group not found') + } + + const stripeAccountId = group.get('stripe_account_id') + if (!stripeAccountId) { + throw new GraphQLError('Group does not have a Stripe account configured') + } + + // Get the Stripe price ID from the offering + const stripePriceId = offering.get('stripe_price_id') + if (!stripePriceId) { + throw new GraphQLError('Offering does not have a Stripe price ID') + } + + // Convert database ID to external account ID if needed + const externalAccountId = await getExternalAccountId(stripeAccountId) + + // Fetch the actual price from Stripe to calculate the application fee accurately + const priceObject = await StripeService.getPrice(externalAccountId, stripePriceId) + + // Calculate the total amount (price * quantity) + const totalAmount = priceObject.unit_amount * (quantity || 1) + + // Calculate application fee (7% of total) + // TODO STRIPE: Consider making this configurable per group or product + const applicationFeePercentage = 0.07 // 7% + const applicationFeeAmount = Math.round(totalAmount * applicationFeePercentage) + + // Determine checkout mode based on renewal policy + // If automatic renewal, use subscription mode; otherwise payment mode + const renewalPolicy = offering.get('renewal_policy') + const checkoutMode = renewalPolicy === 'automatic' ? 'subscription' : 'payment' + + // Create the checkout session + const checkoutSession = await StripeService.createCheckoutSession({ + accountId: externalAccountId, + priceId: stripePriceId, + quantity: quantity || 1, + applicationFeeAmount, + successUrl: `${successUrl}?session_id={CHECKOUT_SESSION_ID}&offering_id=${offeringId}`, + cancelUrl, + mode: checkoutMode, + metadata: { + groupId, + offeringId, + userId, + priceAmount: priceObject.unit_amount, + currency: priceObject.currency, + ...metadata + } + }) + + return { + sessionId: checkoutSession.id, + url: checkoutSession.url, + success: true + } + } catch (error) { + if (error instanceof GraphQLError) { + throw error + } + console.error('Error in createStripeCheckoutSession:', error) + throw new GraphQLError(`Failed to create checkout session: ${error.message}`) + } + }, + + /** + * Checks the Stripe account status and updates the database + * + * This mutation fetches the current status from Stripe and updates + * the group's stripe status fields in the database. + * + * Usage: + * mutation { + * checkStripeStatus(groupId: "123") { + * success + * message + * chargesEnabled + * payoutsEnabled + * detailsSubmitted + * } + * } + */ + checkStripeStatus: async (userId, { groupId }) => { + try { + // Check if user is authenticated + if (!userId) { + throw new GraphQLError('You must be logged in to check Stripe status') + } + + // Load the group + const group = await Group.find(groupId) + if (!group) { + throw new GraphQLError('Group not found') + } + + // Verify user has permission for this group + const hasAdmin = await GroupMembership.hasResponsibility(userId, groupId, Responsibility.constants.RESP_ADMINISTRATION) + if (!hasAdmin) { + throw new GraphQLError('You must be a group administrator to check Stripe status') + } + + // Get the Stripe account ID from the group + const stripeAccountId = group.get('stripe_account_id') + if (!stripeAccountId) { + throw new GraphQLError('Group does not have a connected Stripe account') + } + + // Get the StripeAccount record to find the external account ID + const stripeAccount = await StripeAccount.where({ id: stripeAccountId }).fetch() + if (!stripeAccount) { + throw new GraphQLError('Stripe account record not found') + } + + const externalAccountId = stripeAccount.get('stripe_account_external_id') + + // Get account status from Stripe + const status = await StripeService.getAccountStatus(externalAccountId) + + // Update the group with the latest status from Stripe + await group.save({ + stripe_charges_enabled: status.charges_enabled, + stripe_payouts_enabled: status.payouts_enabled, + stripe_details_submitted: status.details_submitted + }, { patch: true }) + + return { + success: true, + message: 'Stripe status updated successfully', + chargesEnabled: status.charges_enabled, + payoutsEnabled: status.payouts_enabled, + detailsSubmitted: status.details_submitted + } + } catch (error) { + if (error instanceof GraphQLError) { + throw error + } + console.error('Error in checkStripeStatus:', error) + throw new GraphQLError(`Failed to check Stripe status: ${error.message}`) + } + } +} diff --git a/apps/backend/api/graphql/mutations/stripe.test.js b/apps/backend/api/graphql/mutations/stripe.test.js new file mode 100644 index 0000000000..7849fb620d --- /dev/null +++ b/apps/backend/api/graphql/mutations/stripe.test.js @@ -0,0 +1,657 @@ +/* eslint-disable no-unused-expressions */ +import setup from '../../../test/setup' +import factories from '../../../test/setup/factories' +import mock from 'mock-require' +const { expect } = require('chai') + +/* global StripeAccount, GroupMembership, StripeProduct */ + +// Mock StripeService to avoid real API calls +const mockStripeService = { + createConnectedAccount: async ({ email, country, businessName, groupId }) => ({ + id: 'acct_test_123', + email: email || 'test@example.com', + country: country || 'US', + business_name: businessName || 'Test Business', + charges_enabled: false, + payouts_enabled: false, + details_submitted: false + }), + + connectExistingAccount: async ({ accountId, groupId }) => ({ + id: accountId, + email: 'existing@example.com', + country: 'US', + business_name: 'Existing Business', + charges_enabled: true, + payouts_enabled: true, + details_submitted: true + }), + + createAccountLink: async ({ accountId, returnUrl, refreshUrl }) => ({ + url: `https://connect.stripe.com/setup/c/${accountId}`, + expires_at: Math.floor(Date.now() / 1000) + 3600 + }), + + getAccountStatus: async (accountId) => ({ + id: accountId, + email: 'test@example.com', + charges_enabled: true, + payouts_enabled: true, + details_submitted: true, + requirements: [] + }), + + createProduct: async ({ accountId, name, description, priceInCents, currency }) => ({ + id: 'prod_test_123', + name, + description: description || '', + default_price: 'price_test_123', + active: true + }), + + updateProduct: async ({ accountId, productId, name, description, priceInCents, currency }) => ({ + id: productId, + name: name || 'Updated Product', + description: description || 'Updated description', + default_price: { + id: 'price_test_updated', + unit_amount: priceInCents || 2000, + currency: currency || 'usd' + }, + active: true + }), + + getProducts: async (accountId) => ({ + data: [{ + id: 'prod_test_123', + name: 'Test Product', + description: 'A test product', + default_price: 'price_test_123', + active: true + }] + }), + + getPrice: async (accountId, priceId) => ({ + id: priceId, + unit_amount: 2000, + currency: 'usd' + }), + + createCheckoutSession: async ({ accountId, priceId, quantity, applicationFeeAmount, successUrl, cancelUrl, metadata }) => ({ + id: 'cs_test_123', + url: 'https://checkout.stripe.com/pay/cs_test_123' + }) +} + +// Mock the StripeService BEFORE importing the mutations +mock('../../services/StripeService', mockStripeService) + +// Now import the mutations after the mock is set up +const { + createStripeConnectedAccount, + createStripeAccountLink, + createStripeOffering, + updateStripeOffering, + createStripeCheckoutSession +} = mock.reRequire('./stripe') + +describe('Stripe Mutations', () => { + let user, adminUser, group + + before(async () => { + // Create test entities + user = await factories.user().save() + adminUser = await factories.user().save() + group = await factories.group().save() + + // Add admin user as group administrator + await adminUser.joinGroup(group, { role: GroupMembership.Role.MODERATOR }) + // Add regular user as group member + await user.joinGroup(group) + }) + + after(() => setup.clearDb()) + + describe('createStripeConnectedAccount', () => { + it('creates a connected account for group admins', async () => { + const testGroup = await factories.group().save() + await adminUser.joinGroup(testGroup, { role: GroupMembership.Role.MODERATOR }) + + const result = await createStripeConnectedAccount(adminUser.id, { + groupId: testGroup.id, + email: 'group@example.com', + businessName: 'Test Group', + country: 'US' + }) + + expect(result.success).to.be.true + expect(result.accountId).to.equal('acct_test_123') + expect(result.message).to.equal('Connected account created successfully') + }) + + it('uses group email and name as defaults', async () => { + const testGroup = await factories.group({ name: 'Default Group' }).save() + await adminUser.joinGroup(testGroup, { role: GroupMembership.Role.MODERATOR }) + + const result = await createStripeConnectedAccount(adminUser.id, { + groupId: testGroup.id, + country: 'CA' + }) + + expect(result.success).to.be.true + expect(result.accountId).to.equal('acct_test_123') + }) + + it('rejects creation for non-authenticated users', async () => { + await expect( + createStripeConnectedAccount(null, { + groupId: group.id, + email: 'test@example.com', + businessName: 'Test' + }) + ).to.be.rejectedWith('You must be logged in to create a connected account') + }) + + it('rejects creation for non-admin users', async () => { + await expect( + createStripeConnectedAccount(user.id, { + groupId: group.id, + email: 'test@example.com', + businessName: 'Test' + }) + ).to.be.rejectedWith('You must be a group administrator to create a connected account') + }) + + it('rejects creation for non-existent group', async () => { + await expect( + createStripeConnectedAccount(adminUser.id, { + groupId: 99999, + email: 'test@example.com', + businessName: 'Test' + }) + ).to.be.rejectedWith('Group not found') + }) + + // Note: This test uses existingAccountId parameter which is not currently implemented + it.skip('connects existing Stripe account when existingAccountId provided', async () => { + // TODO: Implement existingAccountId parameter in createStripeConnectedAccount mutation + }) + + it('rejects connection if group already has Stripe account', async () => { + const testGroup = await factories.group().save() + await adminUser.joinGroup(testGroup, { role: GroupMembership.Role.MODERATOR }) + + // First, create an account + await createStripeConnectedAccount(adminUser.id, { + groupId: testGroup.id, + email: 'group@example.com', + businessName: 'Test Group', + country: 'US' + }) + + // Try to create another account + await expect( + createStripeConnectedAccount(adminUser.id, { + groupId: testGroup.id, + email: 'group2@example.com', + businessName: 'Test Group 2', + country: 'US' + }) + ).to.be.rejectedWith('This group already has a Stripe account connected') + }) + + // Note: These tests use existingAccountId parameter which is not currently implemented + // in createStripeConnectedAccount mutation. Skipping until feature is added. + it.skip('rejects connection with invalid existing account ID', async () => { + // TODO: Implement existingAccountId parameter in createStripeConnectedAccount mutation + }) + + it.skip('allows connection of unverified accounts', async () => { + // TODO: Implement existingAccountId parameter in createStripeConnectedAccount mutation + }) + }) + + describe('createStripeAccountLink', () => { + it('creates an account link for group admins', async () => { + const result = await createStripeAccountLink(adminUser.id, { + groupId: group.id, + accountId: 'acct_test_123', + returnUrl: 'https://example.com/return', + refreshUrl: 'https://example.com/refresh' + }) + + expect(result.success).to.be.true + expect(result.url).to.equal('https://connect.stripe.com/setup/c/acct_test_123') + expect(result.expiresAt).to.be.a('number') + }) + + it('rejects creation for non-authenticated users', async () => { + await expect( + createStripeAccountLink(null, { + groupId: group.id, + accountId: 'acct_test_123', + returnUrl: 'https://example.com/return', + refreshUrl: 'https://example.com/refresh' + }) + ).to.be.rejectedWith('You must be logged in to create an account link') + }) + + it('rejects creation for non-admin users', async () => { + await expect( + createStripeAccountLink(user.id, { + groupId: group.id, + accountId: 'acct_test_123', + returnUrl: 'https://example.com/return', + refreshUrl: 'https://example.com/refresh' + }) + ).to.be.rejectedWith('You must be a group administrator to manage payments') + }) + }) + + describe('createStripeOffering', () => { + before(async () => { + // Create a stripe_account for these tests + const testStripeAccount = await StripeAccount.forge({ + stripe_account_external_id: 'acct_test_123' + }).save() + + await group.save({ stripe_account_id: testStripeAccount.id }) + }) + + it('creates an offering for group admins', async () => { + const accessGrants = { + trackIds: [1, 2], + groupRoleIds: [1], + groupIds: [group.id] + } + + const result = await createStripeOffering(adminUser.id, { + groupId: group.id, + accountId: 'acct_test_123', + name: 'Premium Membership', + description: 'Access to premium content', + priceInCents: 2000, + currency: 'usd', + accessGrants, + renewalPolicy: 'manual', + duration: 'lifetime', + publishStatus: 'published' + }) + + expect(result.success).to.be.true + expect(result.productId).to.equal('prod_test_123') + expect(result.priceId).to.equal('price_test_123') + expect(result.name).to.equal('Premium Membership') + expect(result.databaseId).to.exist + + // Verify the product was saved to database + const savedProduct = await StripeProduct.where({ id: result.databaseId }).fetch() + expect(savedProduct.get('group_id')).to.equal(group.id) + expect(savedProduct.get('stripe_product_id')).to.equal('prod_test_123') + expect(savedProduct.get('name')).to.equal('Premium Membership') + expect(savedProduct.get('price_in_cents')).to.equal(2000) + expect(savedProduct.get('access_grants')).to.deep.equal(accessGrants) + expect(savedProduct.get('renewal_policy')).to.equal('manual') + expect(savedProduct.get('duration')).to.equal('lifetime') + expect(savedProduct.get('publish_status')).to.equal('published') + }) + + it('creates an offering with minimal required fields', async () => { + const result = await createStripeOffering(adminUser.id, { + groupId: group.id, + accountId: 'acct_test_123', + name: 'Basic Product', + description: 'A basic product', + priceInCents: 1000 + }) + + expect(result.success).to.be.true + expect(result.name).to.equal('Basic Product') + + const savedProduct = await StripeProduct.where({ id: result.databaseId }).fetch() + expect(savedProduct.get('currency')).to.equal('usd') // default + expect(savedProduct.get('access_grants')).to.deep.equal({}) // default + expect(savedProduct.get('renewal_policy')).to.equal('manual') // default + expect(savedProduct.get('publish_status')).to.equal('unpublished') // default + }) + + it('rejects creation for non-authenticated users', async () => { + await expect( + createStripeOffering(null, { + groupId: group.id, + accountId: 'acct_test_123', + name: 'Test Product', + priceInCents: 1000 + }) + ).to.be.rejectedWith('You must be logged in to create an offering') + }) + + it('rejects creation for non-admin users', async () => { + await expect( + createStripeOffering(user.id, { + groupId: group.id, + accountId: 'acct_test_123', + name: 'Test Product', + priceInCents: 1000 + }) + ).to.be.rejectedWith('You must be a group administrator to create offerings') + }) + }) + + describe('updateStripeOffering', () => { + let testProduct + + before(async () => { + // Create a stripe_account for these tests + const testStripeAccount = await StripeAccount.forge({ + stripe_account_external_id: 'acct_test_123' + }).save() + + await group.save({ stripe_account_id: testStripeAccount.id }) + + // Create a test offering + const result = await createStripeOffering(adminUser.id, { + groupId: group.id, + accountId: 'acct_test_123', + name: 'Original Product', + description: 'Original description', + priceInCents: 1000, + currency: 'usd', + accessGrants: { groupIds: [group.id], trackIds: [1] }, + renewalPolicy: 'manual', + duration: 'month', + publishStatus: 'unpublished' + }) + testProduct = await StripeProduct.where({ id: result.databaseId }).fetch() + }) + + it('updates offering name and description', async () => { + const result = await updateStripeOffering(adminUser.id, { + offeringId: testProduct.id, + name: 'Updated Product Name', + description: 'Updated description' + }) + + expect(result.success).to.be.true + expect(result.message).to.equal('Offering updated successfully') + + // Verify the changes were saved (values come from mocked StripeService) + await testProduct.refresh() + expect(testProduct.get('name')).to.equal('Updated Product Name') + expect(testProduct.get('description')).to.equal('Updated description') + }) + + it('updates offering price and currency', async () => { + const result = await updateStripeOffering(adminUser.id, { + offeringId: testProduct.id, + priceInCents: 2500, + currency: 'eur' + }) + + expect(result.success).to.be.true + + // Verify the changes were saved (values come from mocked StripeService) + await testProduct.refresh() + expect(testProduct.get('price_in_cents')).to.equal(2500) + expect(testProduct.get('currency')).to.equal('eur') + expect(testProduct.get('stripe_price_id')).to.equal('price_test_updated') + }) + + it('updates access grants configuration', async () => { + const newAccessGrants = { + trackIds: [1, 2, 3], + groupRoleIds: [1, 2], + groupIds: [group.id] + } + + const result = await updateStripeOffering(adminUser.id, { + offeringId: testProduct.id, + accessGrants: newAccessGrants + }) + + expect(result.success).to.be.true + + // Verify the changes were saved + await testProduct.refresh() + expect(testProduct.get('access_grants')).to.deep.equal(newAccessGrants) + }) + + it('updates renewal policy and duration', async () => { + const result = await updateStripeOffering(adminUser.id, { + offeringId: testProduct.id, + renewalPolicy: 'automatic', + duration: 'annual' + }) + + expect(result.success).to.be.true + + // Verify the changes were saved + await testProduct.refresh() + expect(testProduct.get('renewal_policy')).to.equal('automatic') + expect(testProduct.get('duration')).to.equal('annual') + }) + + it('updates publish status to all valid values', async () => { + // Test unpublished + let result = await updateStripeOffering(adminUser.id, { + offeringId: testProduct.id, + publishStatus: 'unpublished' + }) + expect(result.success).to.be.true + await testProduct.refresh() + expect(testProduct.get('publish_status')).to.equal('unpublished') + + // Test unlisted + result = await updateStripeOffering(adminUser.id, { + offeringId: testProduct.id, + publishStatus: 'unlisted' + }) + expect(result.success).to.be.true + await testProduct.refresh() + expect(testProduct.get('publish_status')).to.equal('unlisted') + + // Test published + result = await updateStripeOffering(adminUser.id, { + offeringId: testProduct.id, + publishStatus: 'published' + }) + expect(result.success).to.be.true + await testProduct.refresh() + expect(testProduct.get('publish_status')).to.equal('published') + + // Test archived + result = await updateStripeOffering(adminUser.id, { + offeringId: testProduct.id, + publishStatus: 'archived' + }) + expect(result.success).to.be.true + await testProduct.refresh() + expect(testProduct.get('publish_status')).to.equal('archived') + }) + + it('rejects invalid publish status', async () => { + await expect( + updateStripeOffering(adminUser.id, { + offeringId: testProduct.id, + publishStatus: 'invalid_status' + }) + ).to.be.rejectedWith('Invalid publish status. Must be unpublished, unlisted, published, or archived') + }) + + it('rejects update for non-authenticated users', async () => { + await expect( + updateStripeOffering(null, { + offeringId: testProduct.id, + name: 'Unauthorized Update' + }) + ).to.be.rejectedWith('You must be logged in to update an offering') + }) + + it('rejects update for non-admin users', async () => { + await expect( + updateStripeOffering(user.id, { + offeringId: testProduct.id, + name: 'Unauthorized Update' + }) + ).to.be.rejectedWith('You must be a group administrator to update offerings') + }) + + it('rejects update for non-existent offering', async () => { + await expect( + updateStripeOffering(adminUser.id, { + offeringId: 99999, + name: 'Non-existent Offering' + }) + ).to.be.rejectedWith('Offering not found') + }) + + it('rejects update when group has no Stripe account', async () => { + // Create a group without a Stripe account + const groupWithoutStripe = await factories.group().save() + await adminUser.joinGroup(groupWithoutStripe, { role: GroupMembership.Role.MODERATOR }) + + const productWithoutStripe = await StripeProduct.create({ + group_id: groupWithoutStripe.id, + stripe_product_id: 'prod_no_stripe', + stripe_price_id: 'price_no_stripe', + name: 'Product Without Stripe', + description: 'A product for a group without Stripe', + price_in_cents: 1000, + currency: 'usd' + }) + + await expect( + updateStripeOffering(adminUser.id, { + offeringId: productWithoutStripe.id, + name: 'Updated Name' + }) + ).to.be.rejectedWith('Group does not have a connected Stripe account') + }) + }) + + describe('createStripeCheckoutSession', () => { + let testOffering + + before(async () => { + // Create a stripe_account for these tests + const testStripeAccount = await StripeAccount.forge({ + stripe_account_external_id: 'acct_test_123' + }).save() + + await group.save({ stripe_account_id: testStripeAccount.id }) + + // Create a test offering + const result = await createStripeOffering(adminUser.id, { + groupId: group.id, + accountId: 'acct_test_123', + name: 'Test Offering', + description: 'A test offering', + priceInCents: 2000, + currency: 'usd', + accessGrants: { groupIds: [group.id] }, + publishStatus: 'published' + }) + testOffering = await StripeProduct.where({ id: result.databaseId }).fetch() + }) + + it('creates a checkout session using offeringId', async () => { + const result = await createStripeCheckoutSession(user.id, { + groupId: group.id, + offeringId: testOffering.id, + quantity: 1, + successUrl: 'https://example.com/success', + cancelUrl: 'https://example.com/cancel', + metadata: { source: 'web' } + }) + + expect(result.success).to.be.true + expect(result.sessionId).to.equal('cs_test_123') + expect(result.url).to.equal('https://checkout.stripe.com/pay/cs_test_123') + }) + + it('creates a checkout session with default quantity', async () => { + const result = await createStripeCheckoutSession(user.id, { + groupId: group.id, + offeringId: testOffering.id, + successUrl: 'https://example.com/success', + cancelUrl: 'https://example.com/cancel' + }) + + expect(result.success).to.be.true + expect(result.sessionId).to.equal('cs_test_123') + }) + + it('allows unauthenticated checkout sessions', async () => { + const result = await createStripeCheckoutSession(null, { + groupId: group.id, + offeringId: testOffering.id, + successUrl: 'https://example.com/success', + cancelUrl: 'https://example.com/cancel' + }) + + expect(result.success).to.be.true + expect(result.sessionId).to.equal('cs_test_123') + }) + + it('rejects checkout session for non-existent offering', async () => { + await expect( + createStripeCheckoutSession(user.id, { + groupId: group.id, + offeringId: 99999, + successUrl: 'https://example.com/success', + cancelUrl: 'https://example.com/cancel' + }) + ).to.be.rejectedWith('Offering not found') + }) + + it('rejects checkout session when offering does not belong to group', async () => { + const otherGroup = await factories.group().save() + const otherStripeAccount = await StripeAccount.forge({ + stripe_account_external_id: 'acct_other_123' + }).save() + await otherGroup.save({ stripe_account_id: otherStripeAccount.id }) + + // Make adminUser an admin of otherGroup so they can create an offering + await adminUser.joinGroup(otherGroup, { role: GroupMembership.Role.MODERATOR }) + + const otherOffering = await createStripeOffering(adminUser.id, { + groupId: otherGroup.id, + accountId: 'acct_other_123', + name: 'Other Group Offering', + priceInCents: 1000 + }) + const otherOfferingModel = await StripeProduct.where({ id: otherOffering.databaseId }).fetch() + + await expect( + createStripeCheckoutSession(user.id, { + groupId: group.id, + offeringId: otherOfferingModel.id, + successUrl: 'https://example.com/success', + cancelUrl: 'https://example.com/cancel' + }) + ).to.be.rejectedWith('Offering does not belong to the specified group') + }) + + it('rejects checkout session when group has no Stripe account', async () => { + const groupWithoutStripe = await factories.group().save() + const offeringWithoutStripe = await StripeProduct.create({ + group_id: groupWithoutStripe.id, + stripe_product_id: 'prod_no_stripe', + stripe_price_id: 'price_no_stripe', + name: 'Offering Without Stripe', + price_in_cents: 1000, + currency: 'usd' + }) + + await expect( + createStripeCheckoutSession(user.id, { + groupId: groupWithoutStripe.id, + offeringId: offeringWithoutStripe.id, + successUrl: 'https://example.com/success', + cancelUrl: 'https://example.com/cancel' + }) + ).to.be.rejectedWith('Group does not have a Stripe account configured') + }) + }) +}) diff --git a/apps/backend/api/graphql/mutations/track.js b/apps/backend/api/graphql/mutations/track.js index b49f0a2633..f73565fd64 100644 --- a/apps/backend/api/graphql/mutations/track.js +++ b/apps/backend/api/graphql/mutations/track.js @@ -45,9 +45,21 @@ export async function duplicateTrack (userId, trackId) { } export async function enrollInTrack (userId, trackId) { - // TODO: check if the user can see the track? + const track = await Track.find(trackId) + if (!track) { + throw new GraphQLError('Track not found') + } + + // Check if track is access-controlled and user has access + if (track.get('access_controlled')) { + const hasAccess = await track.canAccess(userId) + if (!hasAccess) { + throw new GraphQLError('You do not have access to this track. Please purchase access to enroll.') + } + } + await Track.enroll(trackId, userId) - return Track.find(trackId) + return track } export async function leaveTrack (userId, trackId) { diff --git a/apps/backend/api/graphql/queries/contentAccess.js b/apps/backend/api/graphql/queries/contentAccess.js new file mode 100644 index 0000000000..b1f72ef47e --- /dev/null +++ b/apps/backend/api/graphql/queries/contentAccess.js @@ -0,0 +1,70 @@ +/** + * Content Access GraphQL Queries + * + * Provides GraphQL API for querying content access: + * - Checking if a user has access to specific content + */ + +import { GraphQLError } from 'graphql' + +/* global ContentAccess */ + +module.exports = { + + /** + * Check if user has access to content + * + * Query to check if a user has active access to specific content. + * Can be used by the frontend to gate content display. + * + * Usage: + * query { + * checkContentAccess( + * userId: "456" + * grantedByGroupId: "123" + * groupId: "789" // optional - for group-specific access + * productId: "789" // optional - or trackId: "101" or groupRoleId: "202" or commonRoleId: "203" + * ) { + * hasAccess + * accessType + * expiresAt + * } + * } + */ + checkContentAccess: async (sessionUserId, { userId, grantedByGroupId, groupId, productId, trackId, groupRoleId, commonRoleId }) => { + try { + // Check access using the model method + const access = await ContentAccess.checkAccess({ + userId, + grantedByGroupId, + groupId, + productId, + trackId, + groupRoleId, + commonRoleId + }) + + if (!access) { + return { + hasAccess: false, + accessType: null, + expiresAt: null, + grantedAt: null + } + } + + return { + hasAccess: true, + accessType: access.get('access_type'), + expiresAt: access.get('expires_at'), + grantedAt: access.get('created_at') + } + } catch (error) { + if (error instanceof GraphQLError) { + throw error + } + console.error('Error in checkContentAccess:', error) + throw new GraphQLError(`Failed to check access: ${error.message}`) + } + } +} diff --git a/apps/backend/api/graphql/queries/contentAccess.test.js b/apps/backend/api/graphql/queries/contentAccess.test.js new file mode 100644 index 0000000000..012f25849a --- /dev/null +++ b/apps/backend/api/graphql/queries/contentAccess.test.js @@ -0,0 +1,87 @@ +/* eslint-disable no-unused-expressions */ +import setup from '../../../test/setup' +import factories from '../../../test/setup/factories' +import { checkContentAccess } from './contentAccess' +const { expect } = require('chai') + +/* global ContentAccess, GroupMembership, StripeProduct */ + +describe('Content Access Queries', () => { + let user, adminUser, group, product + + before(async () => { + // Create test entities + user = await factories.user().save() + adminUser = await factories.user().save() + group = await factories.group().save() + product = await StripeProduct.forge({ + group_id: group.id, + stripe_product_id: 'prod_test_query_123', + stripe_price_id: 'price_test_query_123', + name: 'Test Product for Query', + description: 'Test Description', + price_in_cents: 1000, + currency: 'usd', + publish_status: 'published' + }).save() + + // Add admin user as group administrator + await adminUser.joinGroup(group, { role: GroupMembership.Role.MODERATOR }) + // Add regular user as group member + await user.joinGroup(group) + }) + + after(() => setup.clearDb()) + + describe('checkContentAccess', () => { + beforeEach(async () => { + // Create an active access record + await ContentAccess.create({ + user_id: user.id, + granted_by_group_id: group.id, + product_id: product.id, + access_type: 'admin_grant', + status: 'active', + metadata: { reason: 'Test access' } + }) + }) + + it('returns access information for user with access', async () => { + const result = await checkContentAccess(user.id, { + userId: user.id, + grantedByGroupId: group.id, + productId: product.id + }) + + expect(result.hasAccess).to.be.true + expect(result.accessType).to.equal('admin_grant') + expect(result.grantedAt).to.exist + }) + + it('returns no access for user without access', async () => { + const otherUser = await factories.user().save() + + const result = await checkContentAccess(otherUser.id, { + userId: otherUser.id, + grantedByGroupId: group.id, + productId: product.id + }) + + expect(result.hasAccess).to.be.false + expect(result.accessType).to.be.null + expect(result.expiresAt).to.be.null + expect(result.grantedAt).to.be.null + }) + + it('returns no access for non-existent product', async () => { + const result = await checkContentAccess(user.id, { + userId: user.id, + grantedByGroupId: group.id, + productId: 99999 + }) + + expect(result.hasAccess).to.be.false + }) + }) +}) + diff --git a/apps/backend/api/graphql/queries/index.js b/apps/backend/api/graphql/queries/index.js new file mode 100644 index 0000000000..8257fe2ee7 --- /dev/null +++ b/apps/backend/api/graphql/queries/index.js @@ -0,0 +1,22 @@ +/** + * GraphQL Queries Index + * + * Exports all query resolvers from the queries folder. + */ + +export { + stripeAccountStatus, + stripeOfferings, + publicStripeOfferings, + publicStripeOffering, + offeringSubscriptionStats, + offeringSubscribers +} from './stripe' + +export { + checkContentAccess +} from './contentAccess' + +export { + myTransactions +} from './transactions' diff --git a/apps/backend/api/graphql/queries/stripe.js b/apps/backend/api/graphql/queries/stripe.js new file mode 100644 index 0000000000..aa1b7dda8e --- /dev/null +++ b/apps/backend/api/graphql/queries/stripe.js @@ -0,0 +1,404 @@ +/** + * Stripe GraphQL Queries + * + * Provides GraphQL API for querying Stripe-related data: + * - Account status + * - Offerings/products + * - Subscription stats + */ + +import { GraphQLError } from 'graphql' +import StripeService from '../../services/StripeService' +import OfferingStatsService from '../../services/OfferingStatsService' + +/* global StripeProduct, Responsibility, GroupMembership, StripeAccount */ + +/** + * Helper function to convert a database account ID to an external Stripe account ID + * If the accountId already starts with 'acct_', it's already the external ID + * Otherwise, look it up from the database + */ +async function getExternalAccountId (accountId) { + // If it already starts with 'acct_', it's already the external ID + if (accountId && accountId.startsWith('acct_')) { + return accountId + } + + // Otherwise, it's a database ID - look up the external account ID + const stripeAccount = await StripeAccount.where({ id: accountId }).fetch() + if (!stripeAccount) { + throw new GraphQLError('Stripe account record not found') + } + return stripeAccount.get('stripe_account_external_id') +} + +module.exports = { + + /** + * Retrieves the status of a connected account + * + * This query fetches the current onboarding status and capabilities + * of a connected account directly from Stripe. + * + * Usage: + * query { + * stripeAccountStatus( + * groupId: "123" + * accountId: "acct_xxx" + * ) { + * chargesEnabled + * payoutsEnabled + * detailsSubmitted + * } + * } + */ + stripeAccountStatus: async (userId, { groupId, accountId }) => { + try { + // Check if user is authenticated + if (!userId) { + throw new GraphQLError('You must be logged in to view account status') + } + + // Verify user has permission for this group + const hasMembership = await GroupMembership.hasActiveMembership(userId, groupId) + if (!hasMembership) { + throw new GraphQLError('You must be a member of this group to view payment status') + } + + // Convert database ID to external account ID if needed + const externalAccountId = await getExternalAccountId(accountId) + + // Get account status from Stripe using the external account ID + const status = await StripeService.getAccountStatus(externalAccountId) + + return { + accountId: status.id, + chargesEnabled: status.charges_enabled, + payoutsEnabled: status.payouts_enabled, + detailsSubmitted: status.details_submitted, + email: status.email, + requirements: status.requirements + } + } catch (error) { + if (error instanceof GraphQLError) { + throw error + } + console.error('Error in stripeAccountStatus:', error) + throw new GraphQLError(`Failed to retrieve account status: ${error.message}`) + } + }, + + /** + * Lists all offerings for a connected account + * + * Fetches offerings from the database (not from Stripe API) to ensure + * we show all offerings including those that may not be synced to Stripe yet. + * + * Usage: + * query { + * stripeOfferings( + * groupId: "123" + * accountId: "acct_xxx" + * ) { + * offerings { + * id + * name + * description + * priceInCents + * currency + * stripeProductId + * stripePriceId + * } + * } + * } + */ + stripeOfferings: async (userId, { groupId, accountId }) => { + try { + // Check if user is authenticated + if (!userId) { + throw new GraphQLError('You must be logged in to view offerings') + } + + // Verify user has permission for this group + const hasAdmin = await GroupMembership.hasResponsibility(userId, groupId, Responsibility.constants.RESP_ADMINISTRATION) + if (!hasAdmin) { + throw new GraphQLError('You must be a group administrator to view offerings') + } + + // Fetch offerings from database for this group + // Return Bookshelf model instances so GraphQL can use the getters defined in makeModels.js + const products = await StripeProduct.where({ group_id: groupId }).fetchAll() + + return { + offerings: products.models, + success: true + } + } catch (error) { + if (error instanceof GraphQLError) { + throw error + } + console.error('Error in stripeOfferings:', error) + throw new GraphQLError(`Failed to retrieve offerings: ${error.message}`) + } + }, + + /** + * Lists published offerings for a group (public, no auth required) + * + * Fetches only published offerings from the database that grant access to the group. + * This is a public query that doesn't require authentication. + * + * Usage: + * query { + * publicStripeOfferings(groupId: "123") { + * offerings { + * id + * name + * description + * priceInCents + * currency + * stripeProductId + * stripePriceId + * accessGrants + * publishStatus + * duration + * } + * success + * } + * } + */ + publicStripeOfferings: async (userId, { groupId }) => { + try { + // Fetch only published offerings from database for this group + const products = await StripeProduct.where({ + group_id: groupId, + publish_status: 'published' + }).fetchAll() + + // Filter to offerings that grant access to this group OR to any track + // (track offerings will be filtered client-side by track ID) + const accessOfferings = products.models.filter(product => { + const accessGrants = product.get('access_grants') + if (!accessGrants) { + return false + } + + // Parse accessGrants (might be string or object) + let grants = {} + if (typeof accessGrants === 'string') { + try { + grants = JSON.parse(accessGrants) + } catch (e) { + console.warn('Failed to parse accessGrants as JSON for product:', product.get('id'), e) + return false + } + } else { + grants = accessGrants + } + + // Check if it includes the current group's ID + if (grants.groupIds && Array.isArray(grants.groupIds)) { + if (grants.groupIds.some(gId => parseInt(gId) === parseInt(groupId))) { + return true + } + } + + // Also include offerings that grant track access (for track paywalls) + if (grants.trackIds && Array.isArray(grants.trackIds) && grants.trackIds.length > 0) { + return true + } + + return false + }) + + return { + offerings: accessOfferings, + success: true + } + } catch (error) { + if (error instanceof GraphQLError) { + throw error + } + console.error('Error in publicStripeOfferings:', error) + throw new GraphQLError(`Failed to retrieve offerings: ${error.message}`) + } + }, + + /** + * Get a single offering by ID (public, no auth required) + * Includes unlisted offerings so they can be accessed via direct link + * + * @param {String|Number} userId - Not used (public query) + * @param {Object} args + * @param {String|Number} args.offeringId - The offering ID to fetch + * @returns {Promise} The offering + * + * Example query: + * { + * publicStripeOffering(offeringId: "123") { + * id + * name + * description + * priceInCents + * currency + * tracks { + * id + * name + * } + * roles { + * id + * name + * } + * } + * } + */ + publicStripeOffering: async (userId, { offeringId }) => { + try { + // Fetch offering by ID from database (no publish_status filter - includes unlisted) + const offering = await StripeProduct.where({ id: offeringId }).fetch() + + if (!offering) { + throw new GraphQLError('Offering not found') + } + + return offering + } catch (error) { + if (error instanceof GraphQLError) { + throw error + } + console.error('Error in publicStripeOffering:', error) + throw new GraphQLError(`Failed to retrieve offering: ${error.message}`) + } + }, + + /** + * Get subscription stats for an offering + * + * Returns active subscriber count, lapsed subscriber count, and monthly revenue. + * Requires group admin permissions. + * + * Usage: + * query { + * offeringSubscriptionStats(offeringId: "123", groupId: "456") { + * activeCount + * lapsedCount + * monthlyRevenueCents + * currency + * success + * } + * } + */ + offeringSubscriptionStats: async (userId, { offeringId, groupId }) => { + try { + // Check if user is authenticated + if (!userId) { + throw new GraphQLError('You must be logged in to view offering stats') + } + + // Verify user has admin permission for this group + const hasAdmin = await GroupMembership.hasResponsibility(userId, groupId, Responsibility.constants.RESP_ADMINISTRATION) + if (!hasAdmin) { + throw new GraphQLError('You must be a group administrator to view offering stats') + } + + // Verify the offering belongs to this group + const offering = await StripeProduct.where({ id: offeringId, group_id: groupId }).fetch() + if (!offering) { + throw new GraphQLError('Offering not found or does not belong to this group') + } + + // Get stats from OfferingStatsService + const stats = await OfferingStatsService.getSubscriptionStats(offeringId) + + // Calculate monthly revenue from Stripe subscriptions + const revenueData = await OfferingStatsService.calculateMonthlyRevenue(offeringId) + + return { + activeCount: stats.activeCount, + lapsedCount: stats.lapsedCount, + monthlyRevenueCents: revenueData.monthlyRevenueCents, + currency: revenueData.currency || offering.get('currency') || 'usd', + success: true, + message: null + } + } catch (error) { + if (error instanceof GraphQLError) { + throw error + } + console.error('Error in offeringSubscriptionStats:', error) + throw new GraphQLError(`Failed to retrieve offering stats: ${error.message}`) + } + }, + + /** + * Get paginated list of subscribers for an offering + * + * Returns a paginated list of users who have subscribed/purchased an offering. + * Requires group admin permissions. + * + * Usage: + * query { + * offeringSubscribers(offeringId: "123", groupId: "456", page: 1, pageSize: 50, lapsedOnly: false) { + * total + * hasMore + * page + * pageSize + * totalPages + * items { + * id + * userId + * userName + * userAvatarUrl + * status + * joinedAt + * expiresAt + * } + * } + * } + */ + offeringSubscribers: async (userId, { offeringId, groupId, page = 1, pageSize = 50, lapsedOnly = false }) => { + try { + // Check if user is authenticated + if (!userId) { + throw new GraphQLError('You must be logged in to view subscribers') + } + + // Verify user has admin permission for this group + const hasAdmin = await GroupMembership.hasResponsibility(userId, groupId, Responsibility.constants.RESP_ADMINISTRATION) + if (!hasAdmin) { + throw new GraphQLError('You must be a group administrator to view subscribers') + } + + // Verify the offering belongs to this group + const offering = await StripeProduct.where({ id: offeringId, group_id: groupId }).fetch() + if (!offering) { + throw new GraphQLError('Offering not found or does not belong to this group') + } + + // Validate pageSize (max 100) + const validPageSize = Math.min(Math.max(1, pageSize), 100) + + // Get subscribers from OfferingStatsService + const result = await OfferingStatsService.getSubscribers(offeringId, { + page, + pageSize: validPageSize, + lapsedOnly + }) + + return { + total: result.total, + hasMore: result.page < result.totalPages, + items: result.subscribers, + page: result.page, + pageSize: result.pageSize, + totalPages: result.totalPages + } + } catch (error) { + if (error instanceof GraphQLError) { + throw error + } + console.error('Error in offeringSubscribers:', error) + throw new GraphQLError(`Failed to retrieve subscribers: ${error.message}`) + } + } +} diff --git a/apps/backend/api/graphql/queries/stripe.test.js b/apps/backend/api/graphql/queries/stripe.test.js new file mode 100644 index 0000000000..3d00583fa7 --- /dev/null +++ b/apps/backend/api/graphql/queries/stripe.test.js @@ -0,0 +1,495 @@ +/* eslint-disable no-unused-expressions */ +import setup from '../../../test/setup' +import factories from '../../../test/setup/factories' +import mock from 'mock-require' +const { expect } = require('chai') + +/* global StripeAccount, GroupMembership, StripeProduct */ + +// Mock StripeService to avoid real API calls +const mockStripeService = { + getAccountStatus: async (accountId) => ({ + id: accountId, + email: 'test@example.com', + charges_enabled: true, + payouts_enabled: true, + details_submitted: true, + requirements: [] + }), + + getProducts: async (accountId) => ({ + data: [{ + id: 'prod_test_123', + name: 'Test Product', + description: 'A test product', + default_price: 'price_test_123', + active: true + }] + }) +} + +// Mock OfferingStatsService for stats queries +const mockOfferingStatsService = { + getSubscriptionStats: async (productId) => ({ + activeCount: 5, + lapsedCount: 2 + }), + calculateMonthlyRevenue: async (productId) => ({ + monthlyRevenueCents: 10000, // $100/month mock revenue + currency: 'usd' + }), + getSubscribers: async (productId, { page = 1, pageSize = 50, lapsedOnly = false } = {}) => { + const allSubscribers = [ + { id: 1, userId: 101, userName: 'Active User 1', userAvatarUrl: 'http://example.com/avatar1.jpg', status: 'active', joinedAt: new Date(), expiresAt: null, stripeSubscriptionId: 'sub_123' }, + { id: 2, userId: 102, userName: 'Active User 2', userAvatarUrl: 'http://example.com/avatar2.jpg', status: 'active', joinedAt: new Date(), expiresAt: null, stripeSubscriptionId: 'sub_124' }, + { id: 3, userId: 103, userName: 'Lapsed User 1', userAvatarUrl: 'http://example.com/avatar3.jpg', status: 'lapsed', joinedAt: new Date(), expiresAt: new Date('2023-01-01'), stripeSubscriptionId: 'sub_125' } + ] + const filtered = lapsedOnly ? allSubscribers.filter(s => s.status === 'lapsed') : allSubscribers + const total = filtered.length + const start = (page - 1) * pageSize + const subscribers = filtered.slice(start, start + pageSize) + return { + subscribers, + total, + page, + pageSize, + totalPages: Math.ceil(total / pageSize) + } + } +} + +// Mock the services BEFORE importing the queries +mock('../../services/StripeService', mockStripeService) +mock('../../services/OfferingStatsService', mockOfferingStatsService) + +// Now import the queries after the mocks are set up +const { + stripeAccountStatus, + stripeOfferings, + publicStripeOfferings, + publicStripeOffering, + offeringSubscriptionStats, + offeringSubscribers +} = mock.reRequire('./stripe') + +describe('Stripe Queries', () => { + let user, adminUser, group + + before(async () => { + // Create test entities + user = await factories.user().save() + adminUser = await factories.user().save() + group = await factories.group().save() + + // Add admin user as group administrator + await adminUser.joinGroup(group, { role: GroupMembership.Role.MODERATOR }) + // Add regular user as group member + await user.joinGroup(group) + }) + + after(() => setup.clearDb()) + + describe('stripeAccountStatus', () => { + it('returns account status for group members', async () => { + const result = await stripeAccountStatus(user.id, { + groupId: group.id, + accountId: 'acct_test_123' + }) + + expect(result.accountId).to.equal('acct_test_123') + expect(result.chargesEnabled).to.be.true + expect(result.payoutsEnabled).to.be.true + expect(result.detailsSubmitted).to.be.true + expect(result.email).to.equal('test@example.com') + }) + + it('rejects status check for non-authenticated users', async () => { + await expect( + stripeAccountStatus(null, { + groupId: group.id, + accountId: 'acct_test_123' + }) + ).to.be.rejectedWith('You must be logged in to view account status') + }) + + it('rejects status check for non-group members', async () => { + const nonMember = await factories.user().save() + + await expect( + stripeAccountStatus(nonMember.id, { + groupId: group.id, + accountId: 'acct_test_123' + }) + ).to.be.rejectedWith('You must be a member of this group to view payment status') + }) + }) + + describe('stripeOfferings', () => { + before(async () => { + // Clean up any existing offerings for this group from previous tests + await StripeProduct.where({ group_id: group.id }).destroy({ require: false }) + + // Create a stripe_account for these tests + const testStripeAccount = await StripeAccount.forge({ + stripe_account_external_id: 'acct_test_123' + }).save() + + await group.save({ stripe_account_id: testStripeAccount.id }) + + // Create some test offerings + await StripeProduct.create({ + group_id: group.id, + stripe_product_id: 'prod_test_1', + stripe_price_id: 'price_test_1', + name: 'Test Offering 1', + description: 'A test offering', + price_in_cents: 1000, + currency: 'usd', + publish_status: 'published' + }) + + await StripeProduct.create({ + group_id: group.id, + stripe_product_id: 'prod_test_2', + stripe_price_id: 'price_test_2', + name: 'Test Offering 2', + description: 'Another test offering', + price_in_cents: 2000, + currency: 'usd', + publish_status: 'unpublished' + }) + }) + + it('lists offerings for group admins', async () => { + const result = await stripeOfferings(adminUser.id, { + groupId: group.id, + accountId: 'acct_test_123' + }) + + expect(result.success).to.be.true + expect(result.offerings).to.have.length(2) + }) + + it('rejects listing for non-authenticated users', async () => { + await expect( + stripeOfferings(null, { + groupId: group.id, + accountId: 'acct_test_123' + }) + ).to.be.rejectedWith('You must be logged in to view offerings') + }) + + it('rejects listing for non-admin users', async () => { + await expect( + stripeOfferings(user.id, { + groupId: group.id, + accountId: 'acct_test_123' + }) + ).to.be.rejectedWith('You must be a group administrator to view offerings') + }) + }) + + describe('publicStripeOfferings', () => { + let testGroup + + before(async () => { + // Create a stripe_account for these tests + const testStripeAccount = await StripeAccount.forge({ + stripe_account_external_id: 'acct_test_123' + }).save() + + testGroup = await factories.group().save() + await testGroup.save({ stripe_account_id: testStripeAccount.id }) + + // Create published offering with group access + await StripeProduct.create({ + group_id: testGroup.id, + stripe_product_id: 'prod_published_1', + stripe_price_id: 'price_published_1', + name: 'Published Offering', + description: 'A published offering', + price_in_cents: 1000, + currency: 'usd', + access_grants: JSON.stringify({ groupIds: [testGroup.id] }), + publish_status: 'published' + }) + + // Create unpublished offering (should not appear) + await StripeProduct.create({ + group_id: testGroup.id, + stripe_product_id: 'prod_unpublished_1', + stripe_price_id: 'price_unpublished_1', + name: 'Unpublished Offering', + description: 'An unpublished offering', + price_in_cents: 2000, + currency: 'usd', + access_grants: JSON.stringify({ groupIds: [testGroup.id] }), + publish_status: 'unpublished' + }) + + // Create published offering without group access (should not appear) + await StripeProduct.create({ + group_id: testGroup.id, + stripe_product_id: 'prod_no_access_1', + stripe_price_id: 'price_no_access_1', + name: 'No Group Access Offering', + description: 'An offering without group access', + price_in_cents: 3000, + currency: 'usd', + access_grants: JSON.stringify({ trackIds: [1] }), + publish_status: 'published' + }) + }) + + it('returns only published offerings with group access', async () => { + const result = await publicStripeOfferings(null, { + groupId: testGroup.id + }) + + expect(result.success).to.be.true + expect(result.offerings).to.have.length(1) + expect(result.offerings[0].get('name')).to.equal('Published Offering') + expect(result.offerings[0].get('publish_status')).to.equal('published') + }) + + it('works without authentication', async () => { + const result = await publicStripeOfferings(null, { + groupId: testGroup.id + }) + + expect(result.success).to.be.true + expect(result.offerings).to.be.an('array') + }) + + it('returns empty array when no matching offerings exist', async () => { + const emptyGroup = await factories.group().save() + + const result = await publicStripeOfferings(null, { + groupId: emptyGroup.id + }) + + expect(result.success).to.be.true + expect(result.offerings).to.have.length(0) + }) + }) + + describe('publicStripeOffering', () => { + let testOffering + + before(async () => { + testOffering = await StripeProduct.create({ + group_id: group.id, + stripe_product_id: 'prod_single_test', + stripe_price_id: 'price_single_test', + name: 'Single Offering', + description: 'A single test offering', + price_in_cents: 1500, + currency: 'usd', + publish_status: 'unlisted' + }) + }) + + it('returns a single offering by ID', async () => { + const result = await publicStripeOffering(null, { + offeringId: testOffering.id + }) + + expect(result.get('name')).to.equal('Single Offering') + expect(result.get('price_in_cents')).to.equal(1500) + }) + + it('works without authentication', async () => { + const result = await publicStripeOffering(null, { + offeringId: testOffering.id + }) + + expect(result).to.exist + expect(result.get('name')).to.equal('Single Offering') + }) + + it('returns unlisted offerings', async () => { + const result = await publicStripeOffering(null, { + offeringId: testOffering.id + }) + + expect(result.get('publish_status')).to.equal('unlisted') + }) + + it('rejects non-existent offering', async () => { + await expect( + publicStripeOffering(null, { + offeringId: 99999 + }) + ).to.be.rejectedWith('Offering not found') + }) + }) + + describe('offeringSubscriptionStats', () => { + let testOffering + + before(async () => { + testOffering = await StripeProduct.create({ + group_id: group.id, + stripe_product_id: 'prod_stats_test', + stripe_price_id: 'price_stats_test', + name: 'Stats Test Offering', + description: 'An offering for testing stats', + price_in_cents: 2000, + currency: 'usd', + publish_status: 'published' + }) + }) + + it('returns subscription stats for group admins', async () => { + const result = await offeringSubscriptionStats(adminUser.id, { + offeringId: testOffering.id, + groupId: group.id + }) + + expect(result.success).to.be.true + expect(result.activeCount).to.equal(5) + expect(result.lapsedCount).to.equal(2) + expect(result.monthlyRevenueCents).to.equal(10000) // From mock + expect(result.currency).to.equal('usd') + }) + + it('rejects stats for non-authenticated users', async () => { + await expect( + offeringSubscriptionStats(null, { + offeringId: testOffering.id, + groupId: group.id + }) + ).to.be.rejectedWith('You must be logged in to view offering stats') + }) + + it('rejects stats for non-admin users', async () => { + await expect( + offeringSubscriptionStats(user.id, { + offeringId: testOffering.id, + groupId: group.id + }) + ).to.be.rejectedWith('You must be a group administrator to view offering stats') + }) + + it('rejects stats for non-existent offering', async () => { + await expect( + offeringSubscriptionStats(adminUser.id, { + offeringId: 99999, + groupId: group.id + }) + ).to.be.rejectedWith('Offering not found or does not belong to this group') + }) + + it('rejects stats when offering belongs to different group', async () => { + const otherGroup = await factories.group().save() + // Make adminUser an admin of otherGroup so the admin check passes + // and we can test the offering ownership check + await adminUser.joinGroup(otherGroup, { role: GroupMembership.Role.MODERATOR }) + + await expect( + offeringSubscriptionStats(adminUser.id, { + offeringId: testOffering.id, + groupId: otherGroup.id + }) + ).to.be.rejectedWith('Offering not found or does not belong to this group') + }) + }) + + describe('offeringSubscribers', () => { + let testOffering + + before(async () => { + testOffering = await StripeProduct.create({ + group_id: group.id, + stripe_product_id: 'prod_subscribers_test', + stripe_price_id: 'price_subscribers_test', + name: 'Subscribers Test Offering', + description: 'An offering for testing subscribers list', + price_in_cents: 2500, + currency: 'usd', + publish_status: 'published' + }) + }) + + it('returns paginated subscribers for group admins', async () => { + const result = await offeringSubscribers(adminUser.id, { + offeringId: testOffering.id, + groupId: group.id, + page: 1, + pageSize: 50 + }) + + expect(result.total).to.equal(3) + expect(result.page).to.equal(1) + expect(result.pageSize).to.equal(50) + expect(result.totalPages).to.equal(1) + expect(result.hasMore).to.be.false + expect(result.items).to.have.length(3) + expect(result.items[0].userName).to.equal('Active User 1') + expect(result.items[0].status).to.equal('active') + }) + + it('filters lapsed subscribers when lapsedOnly is true', async () => { + const result = await offeringSubscribers(adminUser.id, { + offeringId: testOffering.id, + groupId: group.id, + lapsedOnly: true + }) + + expect(result.total).to.equal(1) + expect(result.items).to.have.length(1) + expect(result.items[0].userName).to.equal('Lapsed User 1') + expect(result.items[0].status).to.equal('lapsed') + }) + + it('limits pageSize to 100 maximum', async () => { + const result = await offeringSubscribers(adminUser.id, { + offeringId: testOffering.id, + groupId: group.id, + pageSize: 200 // Should be limited to 100 + }) + + // The mock returns all 3, but the resolver should have passed 100 as pageSize + expect(result.pageSize).to.equal(100) + }) + + it('rejects subscribers query for non-authenticated users', async () => { + await expect( + offeringSubscribers(null, { + offeringId: testOffering.id, + groupId: group.id + }) + ).to.be.rejectedWith('You must be logged in to view subscribers') + }) + + it('rejects subscribers query for non-admin users', async () => { + await expect( + offeringSubscribers(user.id, { + offeringId: testOffering.id, + groupId: group.id + }) + ).to.be.rejectedWith('You must be a group administrator to view subscribers') + }) + + it('rejects subscribers query for non-existent offering', async () => { + await expect( + offeringSubscribers(adminUser.id, { + offeringId: 99999, + groupId: group.id + }) + ).to.be.rejectedWith('Offering not found or does not belong to this group') + }) + + it('rejects subscribers query when offering belongs to different group', async () => { + const otherGroup = await factories.group().save() + await adminUser.joinGroup(otherGroup, { role: GroupMembership.Role.MODERATOR }) + + await expect( + offeringSubscribers(adminUser.id, { + offeringId: testOffering.id, + groupId: otherGroup.id + }) + ).to.be.rejectedWith('Offering not found or does not belong to this group') + }) + }) +}) diff --git a/apps/backend/api/graphql/queries/transactions.js b/apps/backend/api/graphql/queries/transactions.js new file mode 100644 index 0000000000..78f0467fc4 --- /dev/null +++ b/apps/backend/api/graphql/queries/transactions.js @@ -0,0 +1,290 @@ +/** + * User Transactions GraphQL Queries + * + * Provides GraphQL API for querying the current user's transactions/purchases. + */ + +import { GraphQLError } from 'graphql' +import StripeService from '../../services/StripeService' + +/* global ContentAccess, StripeAccount */ + +// Frontend URL for billing portal return +const FRONTEND_URL = process.env.PROTOCOL + '://' + process.env.DOMAIN + +/** + * Get the Stripe account external ID from a group + * + * The group.stripe_account_id is a foreign key to stripe_accounts.id + * We need the stripe_account_external_id (acct_xxx) to make Stripe API calls + * + * @param {Object} group - Group bookshelf model + * @returns {Promise} Stripe account external ID or null + */ +async function getStripeAccountExternalId (group) { + if (!group || !group.get) return null + + const stripeAccountId = group.get('stripe_account_id') + if (!stripeAccountId) return null + + try { + const stripeAccount = await StripeAccount.where({ id: stripeAccountId }).fetch() + if (!stripeAccount) return null + return stripeAccount.get('stripe_account_external_id') || null + } catch (err) { + if (process.env.NODE_ENV === 'development') { + console.error('Error fetching stripe account:', err.message) + } + return null + } +} + +/** + * Enrich a transaction item with Stripe data + * + * Fetches subscription/session details and generates billing portal URL. + * Handles errors gracefully - returns partial data if Stripe calls fail. + * + * @param {Object} item - Transaction item to enrich + * @returns {Promise} Enriched transaction item + */ +async function enrichWithStripeData (item) { + const { _stripeAccountId, _stripeSubscriptionId, _stripeSessionId } = item + + // Skip if no Stripe account ID + if (!_stripeAccountId) { + if (process.env.NODE_ENV === 'development') { + console.log('Skipping Stripe enrichment - no Stripe account ID for transaction:', item.id) + } + return item + } + + if (process.env.NODE_ENV === 'development') { + console.log('Enriching transaction:', item.id, { + stripeAccountId: _stripeAccountId, + subscriptionId: _stripeSubscriptionId, + sessionId: _stripeSessionId + }) + } + + try { + // Get transaction details from Stripe + const stripeData = await StripeService.getTransactionDetails(_stripeAccountId, { + subscriptionId: _stripeSubscriptionId, + sessionId: _stripeSessionId + }) + + if (stripeData) { + item.subscriptionStatus = stripeData.subscriptionStatus + item.currentPeriodEnd = stripeData.currentPeriodEnd + item.amountPaid = stripeData.amountPaid + item.currency = stripeData.currency + item.receiptUrl = stripeData.receiptUrl + + // Generate billing portal URL if we have a customer ID + if (stripeData.customerId) { + try { + // Build return URL to the group page (not /my/transactions) + const groupSlug = item.group?.get ? item.group.get('slug') : null + const returnUrl = groupSlug + ? `${FRONTEND_URL}/groups/${groupSlug}` + : `${FRONTEND_URL}/my/transactions` // Fallback if no group slug + + const portalSession = await StripeService.createBillingPortalSession( + _stripeAccountId, + stripeData.customerId, + returnUrl + ) + item.manageUrl = portalSession.url + } catch (portalError) { + if (process.env.NODE_ENV === 'development') { + console.error('Error creating billing portal:', portalError.message) + } + // Continue without manage URL + } + } + } + } catch (error) { + if (process.env.NODE_ENV === 'development') { + console.error('Error enriching transaction:', item.id, error.message) + } + // Continue with unenriched data + } + + // Remove internal fields before returning + delete item._stripeSessionId + delete item._stripeSubscriptionId + delete item._stripeAccountId + + return item +} + +/** + * Fetch all transactions/purchases for the current user + * + * Queries content_access records where the user made a Stripe purchase. + * Returns transaction data enriched with offering and group information. + * + * @param {String|Number} userId - Current user ID + * @param {Object} args - Query arguments + * @param {Number} args.first - Number of records to return + * @param {Number} args.offset - Pagination offset + * @param {String} args.status - Filter by status: 'active', 'expired', 'revoked', 'refunded' + * @param {String} args.accessType - Filter by access type: 'group', 'track', 'role' + * @param {String|Number} args.offeringId - Filter by offering/product ID + * @param {String} args.paymentType - Filter by payment type: 'subscription', 'one_time' + * @returns {Promise} UserTransactionQuerySet + */ +export async function myTransactions (userId, { first = 20, offset = 0, status, accessType, offeringId, paymentType }) { + if (!userId) { + throw new GraphQLError('You must be logged in to view transactions') + } + + try { + // Build the query for user's purchases (only stripe_purchase, not admin_grant) + const query = ContentAccess.query(q => { + q.where('content_access.user_id', userId) + q.where('content_access.access_type', 'stripe_purchase') + + // Filter by status if provided + if (status) { + q.where('content_access.status', status) + } + + // Filter by offering/product ID if provided + if (offeringId) { + q.where('content_access.product_id', offeringId) + } + + // Filter by payment type (subscription vs one_time) if provided + if (paymentType === 'subscription') { + q.whereNotNull('content_access.stripe_subscription_id') + } else if (paymentType === 'one_time') { + q.whereNull('content_access.stripe_subscription_id') + } + + // Order by purchase date, newest first + q.orderBy('content_access.created_at', 'desc') + }) + + // Get total count for pagination + const countQuery = ContentAccess.query(q => { + q.where('content_access.user_id', userId) + q.where('content_access.access_type', 'stripe_purchase') + if (status) { + q.where('content_access.status', status) + } + if (offeringId) { + q.where('content_access.product_id', offeringId) + } + if (paymentType === 'subscription') { + q.whereNotNull('content_access.stripe_subscription_id') + } else if (paymentType === 'one_time') { + q.whereNull('content_access.stripe_subscription_id') + } + }) + + const totalResult = await countQuery.count() + const total = parseInt(totalResult, 10) + + // Fetch the paginated results with related data + const records = await query + .query(q => { + q.limit(first) + q.offset(offset) + }) + .fetchAll({ + withRelated: ['product', 'grantedByGroup', 'track', 'groupRole', 'commonRole'] + }) + + // Transform records into UserTransaction format + // We need to use Promise.all because we need to fetch Stripe account IDs asynchronously + const baseItemPromises = records.models.map(async record => { + const product = record.related('product') + const group = record.related('grantedByGroup') + const track = record.related('track') + + // Determine the access type based on what access was granted + let derivedAccessType = 'group' + if (record.get('track_id')) { + derivedAccessType = 'track' + } else if (record.get('group_role_id') || record.get('common_role_id')) { + derivedAccessType = 'role' + } else if (record.get('group_id')) { + derivedAccessType = 'group' + } + + // Filter by accessType if provided (done in JS since it's derived) + if (accessType && derivedAccessType !== accessType) { + return null + } + + // Determine payment type based on stripe_subscription_id presence + const paymentType = record.get('stripe_subscription_id') ? 'subscription' : 'one_time' + + // Get metadata for cancellation info + const metadata = record.get('metadata') || {} + + // Get the actual Stripe account external ID (acct_xxx) for API calls + const stripeAccountExternalId = await getStripeAccountExternalId(group) + + return { + id: record.get('id'), + + // From our database + offering: product && product.id ? product : null, + offeringName: product && product.get ? product.get('name') : null, + group, + groupName: group && group.get ? group.get('name') : null, + track: track && track.id ? track : null, + trackName: track && track.get ? track.get('name') : null, + accessType: derivedAccessType, + status: record.get('status'), + purchaseDate: record.get('created_at'), + expiresAt: record.get('expires_at'), + + // Payment type + paymentType, + + // Subscription cancellation info (from metadata) + subscriptionCancelAtPeriodEnd: metadata.subscription_cancel_at_period_end === true || false, + subscriptionPeriodEnd: metadata.subscription_period_end ? new Date(metadata.subscription_period_end) : null, + subscriptionCancellationScheduledAt: metadata.subscription_cancellation_scheduled_at ? new Date(metadata.subscription_cancellation_scheduled_at) : null, + subscriptionCancelReason: metadata.subscription_cancel_reason || null, + + // Stripe data placeholders - will be enriched below + subscriptionStatus: null, + currentPeriodEnd: null, + amountPaid: null, + currency: null, + manageUrl: null, + receiptUrl: null, + + // Store Stripe IDs for enrichment (use external ID, not FK) + _stripeSessionId: record.get('stripe_session_id'), + _stripeSubscriptionId: record.get('stripe_subscription_id'), + _stripeAccountId: stripeAccountExternalId + } + }) + + const baseItems = (await Promise.all(baseItemPromises)).filter(Boolean) // Remove null entries from accessType filtering + + // Enrich items with Stripe data in parallel + const items = await Promise.all(baseItems.map(enrichWithStripeData)) + + // Recalculate hasMore after filtering + const hasMore = offset + items.length < total + + return { + total, + hasMore, + items + } + } catch (error) { + if (error instanceof GraphQLError) { + throw error + } + console.error('Error in myTransactions:', error) + throw new GraphQLError(`Failed to fetch transactions: ${error.message}`) + } +} diff --git a/apps/backend/api/graphql/schema.graphql b/apps/backend/api/graphql/schema.graphql index 8865a38a8a..3fe5f170d0 100644 --- a/apps/backend/api/graphql/schema.graphql +++ b/apps/backend/api/graphql/schema.graphql @@ -7,6 +7,13 @@ enum LocationDisplayPrecision { region } +enum PublishStatus { + unpublished + unlisted + published + archived +} + enum FundingRoundPhase { draft published @@ -20,6 +27,17 @@ type Query { # Find an Activity by ID activity(id: ID): Activity + # Check if a user has access to content + checkContentAccess( + userId: ID!, + grantedByGroupId: ID!, + groupId: ID, + productId: ID, + trackId: ID, + groupRoleId: ID, + commonRoleId: ID + ): ContentAccessCheckResult + # Check if a group invitation is still valid checkInvitation(invitationToken: String, accessCode: String): CheckInvitationResult @@ -30,6 +48,34 @@ type Query { collection(id: ID): Collection + # Query for content access records + contentAccess( + # Filter by one or more group IDs (groups that granted the access) + groupIds: [ID], + # Search by user name + search: String, + # Filter by access type: 'stripe_purchase' or 'admin_grant' + accessType: String, + # Filter by status: 'active', 'expired', or 'revoked' + status: String, + # Filter by offering ID + offeringId: ID, + # Filter by track ID + trackId: ID, + # Filter by group role ID + groupRoleId: ID, + # Filter by common role ID + commonRoleId: ID, + # Number of records to return + first: Int, + # For pagination, start loading after this offset + offset: Int, + # Determines sort order, either 'ASC' or 'DESC' + order: String, + # Sort by 'created_at', 'expires_at', 'user_name' + sortBy: String + ): ContentAccessQuerySet + contextWidgets(groupId: ID, includeUnordered: Boolean): ContextWidgetQuerySet # Query for PersonConnections @@ -49,7 +95,11 @@ type Query { id: ID, slug: String, # Set to true to mark all notifications as read for this group by the current logged in user - updateLastViewed: Boolean + updateLastViewed: Boolean, + # Access code from invitation link - allows viewing the about page for restricted/hidden groups + accessCode: String, + # Invitation token from email invite - allows viewing the about page for restricted/hidden groups + invitationToken: String ): Group # Check whether a Group exists by URL slug @@ -170,6 +220,22 @@ type Query { # Get the currently logged in user me: Me + # Query the current user's transactions/purchases + myTransactions( + # Number of transactions to return + first: Int, + # For pagination, start loading after this offset + offset: Int, + # Filter by status: 'active', 'expired', 'revoked', 'refunded' + status: String, + # Filter by access type: 'group', 'track', 'role' + accessType: String, + # Filter by offering/product ID + offeringId: ID, + # Filter by payment type: 'subscription', 'one_time' + paymentType: String + ): UserTransactionQuerySet + # Query notifications for the currently logged in user notifications( # Number of notifications to load @@ -305,6 +371,47 @@ type Query { offset: Int ): SkillQuerySet, + # Get the status of a connected Stripe account + stripeAccountStatus( + groupId: ID! + accountId: String! + ): StripeAccountStatusResult + + # List all offerings for a connected Stripe account + stripeOfferings( + groupId: ID! + accountId: String! + ): StripeOfferingsResult + + # List published offerings for a group (public, no auth required) + publicStripeOfferings( + groupId: ID! + ): StripeOfferingsResult + # Get a single offering by ID (public, no auth required, includes unlisted) + publicStripeOffering( + offeringId: ID! + ): StripeOffering + + # Get subscription stats for an offering (active count, lapsed count, revenue) + offeringSubscriptionStats( + offeringId: ID! + groupId: ID! + ): OfferingSubscriptionStats + + # Get paginated list of subscribers for an offering + offeringSubscribers( + # The offering (StripeProduct) ID + offeringId: ID! + # The group ID (for permission checks) + groupId: ID! + # Page number (1-indexed, default 1) + page: Int + # Number of results per page (default 50, max 100) + pageSize: Int + # If true, only return lapsed subscribers + lapsedOnly: Boolean + ): OfferingSubscribersQuerySet + # Find a Topic by ID or by name (text) topic(id: ID, name: String): Topic @@ -521,6 +628,14 @@ type Attachment { type CheckInvitationResult { # Whether the invitation is still valid or not valid: Boolean + # The slug of the group for redirect purposes + groupSlug: String + # The email address from the invitation (for token-based invites) + email: String + # The common role that will be assigned when this invitation is used + commonRole: CommonRole + # The group-specific role that will be assigned when this invitation is used + groupRole: GroupRole } type Collection { @@ -625,6 +740,13 @@ type CommentQuerySet { items: [Comment] } +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set +type ContentAccessQuerySet { + total: Int + hasMore: Boolean + items: [ContentAccess] +} + # Common Roles are standard roles that are always available in all groups type CommonRole { id: ID @@ -781,6 +903,8 @@ union FeedItemContent = Post | Interstitial # A Hylo Group type Group { id: ID + # Whether the current user has access to this group (via scope system or free access) + canAccess: Boolean # URL for a video to display at the top of the about text aboutVideoUri: String, # Either 0 = Closed (invite only), 1 = Restricted (Anyone can request to join), 2 = Open (anyone can join if they can find/see the group) @@ -817,6 +941,8 @@ type Group { name: String # For the current logged in user how many prerequisite groups must they join before they can join this one numPrerequisitesLeft: Int + # Whether this group requires purchased membership (paywall enabled) + paywall: Boolean # Total number of posts in this group postCount: Int # The purpose statement for the group @@ -829,6 +955,16 @@ type Group { stewardDescriptor: String # Plural word used to describe stewards of this group. Defaults to "Stewards" stewardDescriptorPlural: String + # Stripe Connected Account ID for accepting payments + stripeAccountId: String + # Direct link to the Stripe Dashboard for this group's connected account + stripeDashboardUrl: String + # Whether the Stripe account can accept charges + stripeChargesEnabled: Boolean + # Whether the Stripe account can receive payouts + stripePayoutsEnabled: Boolean + # Whether all required Stripe details have been submitted + stripeDetailsSubmitted: Boolean # Right now can either by 'farm' or null type: String # Word used to describe this type of group. Defaults to Group, or if type is set then defaults to the type (e.g. Farm) @@ -1173,6 +1309,32 @@ type Group { types: [String] ): PostQuerySet + # Content access records for this group (purchased or granted) + contentAccess( + # Search by user name + search: String, + # Filter by access type: 'stripe_purchase' or 'admin_grant' + accessType: String, + # Filter by status: 'active', 'expired', or 'revoked' + status: String, + # Filter by offering ID + offeringId: ID, + # Filter by track ID + trackId: ID, + # Filter by group role ID + groupRoleId: ID, + # Filter by common role ID + commonRoleId: ID, + # Number of records to return + first: Int, + # For pagination, start loading after this offset + offset: Int, + # Determines sort order, either 'ASC' or 'DESC' + order: String, + # Sort by 'created_at', 'expires_at', 'user_name' + sortBy: String + ): ContentAccessQuerySet + # Widgets used on the Explore page widgets: GroupWidgetQuerySet } @@ -1331,6 +1493,8 @@ type GroupRelationshipInviteQuerySet { type GroupRole { # Whether this role is active active: Boolean + # Whether the current user has access to this role (via scope system) + canAccess: Boolean # When this role was created createdAt: Date # The description of the role @@ -1749,6 +1913,8 @@ type Membership { createdAt: Date # The common roles this member has in this group commonRoles: CommonRoleQuerySet + # When this membership expires (mirrored from content_access table) + expiresAt: Date groupId: ID group: Group # Is this group membership a moderator role. TODO: remove once mobile app has been updated to use new roles @@ -1802,7 +1968,7 @@ type MembershipCommonRole { createdAt: Date group: Group groupId: ID - # same as commonRoleId + # same as commonRoleId (for backwards compatibility) roleId: ID updatedAt: Date user: Person @@ -1820,11 +1986,13 @@ type MembershipCommonRoleQuerySet { type MembershipGroupRole { id: ID createdAt: Date + # When this role membership expires (mirrored from content_access table) + expiresAt: Date group: Group groupId: ID groupRoleId: ID groupRole: GroupRole - # same as groupRoleId + # same as groupRoleId (for backwards compatibility) roleId: ID updatedAt: Date user: Person @@ -2542,6 +2710,10 @@ type TopicFollowSettings { type Track { id: ID + # Whether this track requires purchased access (paid content) + accessControlled: Boolean + # Whether the current user has access to this track (via scope system or free access) + canAccess: Boolean # The word used in this track to describe an action actionDescriptor: String # The word used in this track to describe multiple actions @@ -2614,6 +2786,162 @@ type TrackUser { user: Person } +# Stripe offering for paid content access +type StripeOffering { + id: ID + createdAt: Date + updatedAt: Date + # The group this offering belongs to + group: Group + groupId: ID + # Stripe product ID from Stripe API + stripeProductId: String + # Stripe price ID from Stripe API + stripePriceId: String + # Offering name + name: String + # Offering description + description: String + # Price in cents + priceInCents: Int + # Currency code (e.g. 'usd') + currency: String + # Optional track this offering grants access to (legacy field) + track: Track + trackId: ID + # JSONB object defining what access this offering grants + accessGrants: JSON + # Tracks this offering grants access to (resolved from accessGrants.trackIds) + tracks: [Track] + # Renewal policy: automatic or manual + renewalPolicy: String + # Duration: month, season, annual, lifetime, or null for no expiration + duration: String + # Publish status: unpublished, unlisted, published + publishStatus: PublishStatus +} + +type ContentAccess { + id: ID + createdAt: Date + updatedAt: Date + # User who has access + user: Person + userId: ID + # Group that granted the access + grantedByGroup: Group + grantedByGroupId: ID + # Group access is for (optional) + group: Group + groupId: ID + # Stripe offering (if purchased) + offering: StripeOffering + offeringId: ID + # Track access is for (optional) + track: Track + trackId: ID + # Group role access is for (optional) + groupRole: GroupRole + groupRoleId: ID + # Common role access is for (optional) + commonRole: CommonRole + commonRoleId: ID + # How access was granted: stripe_purchase or admin_grant + accessType: String + # Stripe session ID (for purchases) + stripeSessionId: String + # Stripe subscription ID (for recurring purchases) + stripeSubscriptionId: String + # Current status: active, expired, revoked + status: String + # User who granted access (for admin grants) + grantedBy: Person + grantedById: ID + # When access expires (optional) + expiresAt: Date + # Additional metadata + metadata: JSON + # Subscription cancellation info (extracted from metadata) + subscriptionCancelAtPeriodEnd: Boolean + subscriptionPeriodEnd: Date + subscriptionCancellationScheduledAt: Date + subscriptionCancelReason: String +} + +type ContentAccessResult { + id: ID + userId: ID + grantedByGroupId: ID + groupId: ID + productId: ID + trackId: ID + groupRoleId: ID + commonRoleId: ID + accessType: String + status: String + success: Boolean + message: String +} + +type ContentAccessCheckResult { + hasAccess: Boolean + accessType: String + expiresAt: Date +} + +# A user's transaction/purchase record, enriched with Stripe data +type UserTransaction { + id: ID! + + # From our database + # The offering/product that was purchased + offering: StripeOffering + offeringName: String + # The group that sold this + group: Group! + groupName: String + # Track access granted (if applicable) + track: Track + trackName: String + # Type of access: 'group', 'track', 'role' + accessType: String! + # Our status: 'active', 'expired', 'revoked' + status: String! + # When the purchase was made + purchaseDate: Date! + # When access expires (null for lifetime) + expiresAt: Date + + # From Stripe API (enriched) + # Payment type: 'one_time' or 'subscription' + paymentType: String! + # Stripe subscription status: 'active', 'canceled', 'past_due', etc (null for one-time payments) + subscriptionStatus: String + # When the current billing period ends / next renewal date + currentPeriodEnd: Date + # Subscription cancellation info (extracted from metadata) + subscriptionCancelAtPeriodEnd: Boolean + subscriptionPeriodEnd: Date + subscriptionCancellationScheduledAt: Date + subscriptionCancelReason: String + # Amount paid in cents + amountPaid: Int + # Currency code (e.g., 'usd') + currency: String + + # Generated links + # URL to Stripe billing portal for managing this subscription + manageUrl: String + # URL to view receipt + receiptUrl: String +} + +type UserTransactionQuerySet { + total: Int + hasMore: Boolean + items: [UserTransaction] +} + # A participatory funding round type FundingRound { id: ID @@ -2846,6 +3174,44 @@ type Mutation { cancelJoinRequest(joinRequestId: ID): GenericResult # Clear a moderation action; only a 'content manager' or initial reporter can do this. clearModerationAction(postId: ID, groupId: ID, moderationActionId: ID): GenericResult + # Grant content access to a user (admin only) + grantContentAccess( + userId: ID!, + grantedByGroupId: ID!, + groupId: ID, + productId: ID, + trackId: ID, + groupRoleId: ID, + commonRoleId: ID, + expiresAt: Date, + reason: String + ): ContentAccessResult + # Revoke content access (admin only) + revokeContentAccess( + accessId: ID!, + reason: String + ): ContentAccess + # Refund content access (admin only) - revokes access and issues Stripe refund + refundContentAccess( + accessId: ID!, + reason: String + ): ContentAccess + # Record a Stripe purchase (internal use) + recordStripePurchase( + userId: ID!, + grantedByGroupId: ID!, + groupId: ID, + productId: ID, + trackId: ID, + groupRoleId: ID, + commonRoleId: ID, + sessionId: String!, + stripeSubscriptionId: String, + amountPaid: Int, + currency: String, + expiresAt: Date, + metadata: JSON + ): ContentAccessResult # Mark a post as completed completePost(postId: ID, completionResponse: JSON): Post # For logged in user to add an Affiliation to their profile @@ -2953,7 +3319,9 @@ type Mutation { # Join a funding round joinFundingRound(id: ID): FundingRound # Join a group. Will fail if the person does not have permission to join this group. - joinGroup(groupId: ID, questionAnswers: [QuestionAnswerInput]): Membership + # For Restricted groups, provide accessCode or invitationToken for pre-approved join. + # Set acceptAgreements to true if user has accepted agreements during join flow. + joinGroup(groupId: ID, questionAnswers: [QuestionAnswerInput], accessCode: String, invitationToken: String, acceptAgreements: Boolean): Membership # Join a project Post as a member joinProject(id: ID): GenericResult # Leave a funding round @@ -3163,6 +3531,52 @@ type Mutation { ): SignupResult # Upvote a post, or remove upvote. DEPRECATED, use reactOn instead vote(postId: ID, isUpvote: Boolean): Post + + # Stripe Connect mutations + # Create a Stripe Connected Account for a group + createStripeConnectedAccount( + groupId: ID! + email: String! + businessName: String! + country: String + ): StripeConnectedAccountResult + + # Create an Account Link for onboarding + createStripeAccountLink( + groupId: ID! + accountId: String! + returnUrl: String! + refreshUrl: String! + ): StripeAccountLinkResult + + # Create an offering on the connected account + createStripeOffering(input: StripeOfferingInput!): StripeOfferingResult + + # Update an existing Stripe offering + updateStripeOffering( + offeringId: ID! + name: String + description: String + priceInCents: Int + currency: String + accessGrants: JSON + renewalPolicy: String + duration: String + publishStatus: PublishStatus + ): StripeOfferingUpdateResult + + # Create a checkout session for purchasing a product + createStripeCheckoutSession( + groupId: ID! + offeringId: ID! + quantity: Int + successUrl: String! + cancelUrl: String! + metadata: JSON + ): StripeCheckoutSessionResult + + # Check Stripe account status and update database values + checkStripeStatus(groupId: ID!): StripeStatusCheckResult } # Result of acceptGroupRelationshipInvite mutation @@ -3202,6 +3616,18 @@ type GenericResult { success: Boolean } +# Result of a refund mutation +type RefundResult { + # Did the refund succeed + success: Boolean + # Message describing the result + message: String + # Stripe refund ID + refundId: String + # Amount refunded in cents + refundAmount: Int +} + # Result of a useInvitation mutation type InvitationUseResult { # If the mutation failed this will contain an error string @@ -3218,6 +3644,113 @@ type SignupResult { me: Me } +# Stripe Connect result types +type StripeConnectedAccountResult { + id: ID + accountId: String + success: Boolean + message: String +} + +type StripeAccountLinkResult { + url: String + expiresAt: Date + success: Boolean +} + +type StripeAccountStatusResult { + accountId: String + chargesEnabled: Boolean + payoutsEnabled: Boolean + detailsSubmitted: Boolean + email: String + requirements: JSON +} + +type StripeStatusCheckResult { + success: Boolean + message: String + chargesEnabled: Boolean + payoutsEnabled: Boolean + detailsSubmitted: Boolean +} + +type StripeOfferingResult { + productId: String + priceId: String + name: String + databaseId: ID + success: Boolean + message: String +} + +type StripeOfferingsResult { + offerings: [StripeOffering] + success: Boolean +} + +type StripeOfferingUpdateResult { + offering: StripeOffering + success: Boolean + message: String +} + +type StripeCheckoutSessionResult { + sessionId: String + url: String + success: Boolean +} + +# Stats for an offering's subscribers +type OfferingSubscriptionStats { + # Number of currently active subscribers + activeCount: Int + # Number of lapsed (expired/revoked) subscribers + lapsedCount: Int + # Monthly revenue in cents (requires Stripe API call) + monthlyRevenueCents: Int + # Currency of the offering + currency: String + success: Boolean + message: String +} + +# A subscriber to an offering (StripeProduct) +type OfferingSubscriber { + # The content access record ID + id: ID + # The user ID of the subscriber + userId: ID + # The subscriber's name + userName: String + # The subscriber's avatar URL + userAvatarUrl: String + # Subscription status: 'active' or 'lapsed' + status: String + # When the user subscribed/purchased + joinedAt: Date + # When access expires (if applicable) + expiresAt: Date + # The Stripe subscription ID (if applicable) + stripeSubscriptionId: String +} + +# Paginated list of subscribers for an offering +type OfferingSubscribersQuerySet { + # Total number of subscribers matching the filter + total: Int + # Whether there are more pages to load + hasMore: Boolean + # The subscribers on this page + items: [OfferingSubscriber] + # Current page number (1-indexed) + page: Int + # Number of items per page + pageSize: Int + # Total number of pages + totalPages: Int +} + # An Affilation with an organization added to a User profile input AffiliationInput { # The name of the organization the person is affiliated with @@ -3463,6 +3996,8 @@ input GroupInput { name: String # IDs of parent Groups to add this Group to parentIds: [ID] + # Whether this group requires purchased membership (paywall enabled) + paywall: Boolean # IDs of Groups that someone must join before they can join this one prerequisiteGroupIds: [ID] # Purpose statement @@ -3481,6 +4016,8 @@ input GroupInput { stewardDescriptor: String # Word used to describe stewards of this group (people with a Common Role). Defaults to "Stewards" stewardDescriptorPlural: String + # Stripe Connected Account ID for accepting payments + stripeAccountId: String # Right now can either by 'farm' or empty type: String # Word used to describe this type of group. Defaults to Group, or if type is set then defaults to the type (e.g. Farm) @@ -3563,6 +4100,31 @@ input GroupWidgetSettingsInput { title: String } +# Input for creating Stripe offerings +input StripeOfferingInput { + # The group this offering belongs to + groupId: ID! + # Stripe connected account ID + accountId: String! + # Offering name + name: String! + # Offering description + description: String + # Price in cents + priceInCents: Int! + # Currency code (e.g. 'usd') + currency: String + # JSONB object defining what access this offering grants + # Format: { "trackIds": [1, 2], "groupRoleIds": [3, 4], "commonRoleIds": [5, 6], "groupIds": [7, 8] } + accessGrants: JSON + # Renewal policy: automatic or manual + renewalPolicy: String + # Duration: month, season, annual, lifetime, or null for no expiration + duration: String + # Publish status: unpublished, unlisted, published, archived + publishStatus: PublishStatus +} + # Details passed along when Flagging a Post input InappropriateContentInput { # 'inappropriate', 'offensive', 'abusive', 'illegal', 'safety', 'spam', or 'other' @@ -3576,6 +4138,10 @@ input InappropriateContentInput { input InviteInput { emails: [String] message: String + # Common role ID to assign when the invitation is used (Coordinator=1, Moderator=2, Host=3) + commonRoleId: ID + # Group-specific role ID to assign when the invitation is used + groupRoleId: ID } # A link to a Post or a Person who has been Flagged @@ -3824,6 +4390,8 @@ input SavedSearchInput { } input TrackInput { + # Whether this track requires purchased access (paid content) + accessControlled: Boolean # The word used in this track to describe an action actionDescriptor: String # The word used in this track to describe multiple actions diff --git a/apps/backend/api/models/ContentAccess.js b/apps/backend/api/models/ContentAccess.js new file mode 100644 index 0000000000..1ab73fcc36 --- /dev/null +++ b/apps/backend/api/models/ContentAccess.js @@ -0,0 +1,752 @@ +/* eslint-disable camelcase */ +const { createTrackScope, createGroupRoleScope, createCommonRoleScope, createGroupScope } = require('../../lib/scopes') +const StripeService = require('../services/StripeService') + +module.exports = bookshelf.Model.extend({ + tableName: 'content_access', + requireFetch: false, + hasTimestamps: true, + + /** + * The user who has access + */ + user: function () { + return this.belongsTo(User, 'user_id') + }, + + /** + * The group that grants this access + * Required - this is the group administering the access grant + */ + grantedByGroup: function () { + return this.belongsTo(Group, 'granted_by_group_id') + }, + + /** + * The group this access grant is for (optional) + * Can be null for system-level or cross-group grants + */ + group: function () { + return this.belongsTo(Group, 'group_id') + }, + + /** + * The Stripe product this access was granted for (optional - for paid access) + */ + product: function () { + return this.belongsTo(StripeProduct, 'product_id') + }, + + /** + * Optional track that this access grant is for + * If set, this grants access to a specific track within the group + */ + track: function () { + return this.belongsTo(Track, 'track_id') + }, + + /** + * Optional group role that this access grant is for + * If set, this grants a specific group role within the group + */ + groupRole: function () { + return this.belongsTo(GroupRole, 'group_role_id') + }, + + /** + * Optional common role that this access grant is for + * If set, this grants a specific common role within the group + */ + commonRole: function () { + return this.belongsTo(CommonRole, 'common_role_id') + }, + + /** + * The admin who granted this access (for admin-granted free access) + */ + grantedBy: function () { + return this.belongsTo(User, 'granted_by_id') + }, + + /** + * Check if this access has expired + * @returns {Boolean} + */ + isExpired: function () { + const expiresAt = this.get('expires_at') + return expiresAt && new Date(expiresAt) < new Date() + }, + + /** + * Check if this access is currently active + * @returns {Boolean} + */ + isActive: function () { + return this.get('status') === 'active' && !this.isExpired() + }, + + /** + * Get the scope string that this content access grants + * Note: Each content_access record grants exactly ONE scope - either a track, role, or group scope + * @returns {String|null} Single scope string (e.g., 'group:123', 'track:456', 'group_role:123:789', 'common_role:123:1') + */ + getScope: function () { + const trackId = this.get('track_id') + const groupRoleId = this.get('group_role_id') + const commonRoleId = this.get('common_role_id') + const groupId = this.get('group_id') + const grantedByGroupId = this.get('granted_by_group_id') + + // Each content_access record should only have one of these set + if (trackId) { + return createTrackScope(trackId) + } else if (groupRoleId) { + // Use group_id if available, otherwise fall back to granted_by_group_id + const scopeGroupId = groupId || grantedByGroupId + if (!scopeGroupId) { + console.warn('Cannot create group role scope: missing group_id and granted_by_group_id') + return null + } + return createGroupRoleScope(groupRoleId, scopeGroupId) + } else if (commonRoleId) { + // Use group_id if available, otherwise fall back to granted_by_group_id + const scopeGroupId = groupId || grantedByGroupId + if (!scopeGroupId) { + console.warn('Cannot create common role scope: missing group_id and granted_by_group_id') + return null + } + return createCommonRoleScope(commonRoleId, scopeGroupId) + } else if (groupId) { + return createGroupScope(groupId) + } + + return null + } + +}, { + // Status constants + Status: { + ACTIVE: 'active', + EXPIRED: 'expired', + REVOKED: 'revoked', + REFUNDED: 'refunded' + }, + + // Access type constants + Type: { + STRIPE_PURCHASE: 'stripe_purchase', + ADMIN_GRANT: 'admin_grant' + }, + + /** + * Create a new content access record + * Note: The database triggers will automatically update the user_scopes table + * to materialize the access as scopes for the user. + * + * @param {Object} attrs - Access attributes + * @param {String|Number} attrs.user_id - User receiving access + * @param {String|Number} attrs.granted_by_group_id - Group granting the access (required) + * @param {String|Number} [attrs.group_id] - Group the access is for (optional) + * @param {String} attrs.access_type - Type: 'stripe_purchase' or 'admin_grant' + * @param {String} [attrs.product_id] - Optional Stripe product ID + * @param {String} [attrs.track_id] - Optional track ID + * @param {String} [attrs.group_role_id] - Optional group role ID + * @param {String} [attrs.common_role_id] - Optional common role ID + * @param {Date} [attrs.expires_at] - Optional expiration date + * @param {String} [attrs.stripe_session_id] - For Stripe purchases + * @param {String} [attrs.stripe_subscription_id] - Stripe subscription ID (for recurring purchases) + * @param {String} [attrs.granted_by_id] - Admin who granted access + * @param {Object} [attrs.metadata] - Additional metadata + * @param {Object} options - Options including transacting + * @returns {Promise} + */ + create: async function (attrs, { transacting } = {}) { + // Set defaults + const defaults = { + status: this.Status.ACTIVE, + metadata: {} + } + + // Filter out undefined values to prevent SQL errors + const filteredAttrs = Object.entries(attrs).reduce((acc, [key, value]) => { + if (value !== undefined) { + acc[key] = value + } + return acc + }, {}) + + return this.forge({ ...defaults, ...filteredAttrs }).save({}, { transacting }) + }, + + /** + * Grant free access to content (admin action) + * @param {Object} params + * @param {String|Number} params.userId - User to grant access to + * @param {String|Number} params.grantedByGroupId - Group granting the access (required) + * @param {String|Number} params.groupId - Group to grant access for (optional) + * @param {String|Number} params.grantedById - Admin granting the access (optional) + * @param {String|Number} [params.productId] - Optional product + * @param {String|Number} [params.trackId] - Optional track + * @param {String|Number} [params.groupRoleId] - Optional group role + * @param {String|Number} [params.commonRoleId] - Optional common role + * @param {Date} [params.expiresAt] - Optional expiration + * @param {String} [params.reason] - Reason for granting access (stored in metadata) + * @param {Object} options - Options including transacting + * @returns {Promise} + */ + grantAccess: async function ({ + userId, + grantedByGroupId, + groupId, + grantedById, + productId, + trackId, + groupRoleId, + commonRoleId, + expiresAt, + reason + }, { transacting } = {}) { + const metadata = {} + if (reason) metadata.reason = reason + + return this.create({ + user_id: userId, + granted_by_group_id: grantedByGroupId, + group_id: groupId, + product_id: productId, + track_id: trackId, + group_role_id: groupRoleId, + common_role_id: commonRoleId, + access_type: this.Type.ADMIN_GRANT, + granted_by_id: grantedById, + expires_at: expiresAt, + metadata + }, { transacting }) + }, + + /** + * Record a Stripe purchase (called from webhook handler) + * @param {Object} params + * @param {String|Number} params.userId - User who made the purchase + * @param {String|Number} params.grantedByGroupId - Group making the purchase available (required) + * @param {String|Number} params.groupId - Group the purchase is for (optional) + * @param {String|Number} params.productId - Stripe product ID (from stripe_products table) + * @param {String|Number} [params.trackId] - Optional track + * @param {String|Number} [params.groupRoleId] - Optional group role + * @param {String|Number} [params.commonRoleId] - Optional common role + * @param {String} params.sessionId - Stripe checkout session ID + * @param {String} [params.stripeSubscriptionId] - Stripe subscription ID (for recurring purchases) + * @param {Date} [params.expiresAt] - When access expires + * @param {Object} [params.metadata] - Additional metadata + * @param {Object} options - Options including transacting + * @returns {Promise} + */ + recordPurchase: async function ({ + userId, + grantedByGroupId, + groupId, + productId, + trackId, + groupRoleId, + commonRoleId, + sessionId, + stripeSubscriptionId, + expiresAt, + metadata = {} + }, { transacting } = {}) { + return this.create({ + user_id: userId, + granted_by_group_id: grantedByGroupId, + group_id: groupId, + product_id: productId, + track_id: trackId, + group_role_id: groupRoleId, + common_role_id: commonRoleId, + access_type: this.Type.STRIPE_PURCHASE, + stripe_session_id: sessionId, + stripe_subscription_id: stripeSubscriptionId, + expires_at: expiresAt, + metadata + }, { transacting }) + }, + + /** + * Revoke access (changes status to revoked) + * Also cancels any associated Stripe subscription. + * Note: Database triggers will automatically remove the corresponding scopes + * from the user_scopes table. + * + * @param {String|Number} accessId - The access record ID + * @param {String|Number} revokedById - Admin who revoked the access + * @param {String} [reason] - Reason for revocation + * @param {Object} options - Options including transacting + * @returns {Promise} + */ + revoke: async function (accessId, revokedById, reason, { transacting } = {}) { + const access = await this.where({ id: accessId }).fetch({ + transacting, + withRelated: ['grantedByGroup'] + }) + if (!access) { + throw new Error('Access record not found') + } + + const subscriptionId = access.get('stripe_subscription_id') + const grantedByGroup = access.related('grantedByGroup') + + // Cancel the Stripe subscription if one exists + if (subscriptionId && grantedByGroup) { + const stripeAccountId = grantedByGroup.get('stripe_account_id') + if (stripeAccountId) { + try { + // Get the external Stripe account ID from StripeAccount model + const stripeAccount = await StripeAccount.where({ id: stripeAccountId }).fetch() + if (stripeAccount) { + const externalAccountId = stripeAccount.get('stripe_account_external_id') + await StripeService.cancelSubscription({ + accountId: externalAccountId, + subscriptionId, + immediately: true + }) + } + } catch (error) { + // Log but don't fail the revocation if subscription cancellation fails + console.error(`Failed to cancel subscription ${subscriptionId}:`, error.message) + } + } + } + + const metadata = access.get('metadata') || {} + metadata.revokedAt = new Date().toISOString() + metadata.revokedBy = revokedById + if (reason) metadata.revokeReason = reason + if (subscriptionId) metadata.subscriptionCancelled = true + + return access.save({ + status: this.Status.REVOKED, + metadata + }, { transacting }) + }, + + /** + * Check if a user has active access to content + * @param {Object} params + * @param {String|Number} params.userId - User to check + * @param {String|Number} params.grantedByGroupId - Group granting the access (required) + * @param {String|Number} [params.groupId] - Specific group access is for (optional) + * @param {String|Number} [params.productId] - Optional product + * @param {String|Number} [params.trackId] - Optional track + * @param {String|Number} [params.groupRoleId] - Optional group role + * @param {String|Number} [params.commonRoleId] - Optional common role + * @returns {Promise} + */ + checkAccess: async function ({ userId, grantedByGroupId, groupId, productId, trackId, groupRoleId, commonRoleId }) { + const query = this.where({ + user_id: userId, + granted_by_group_id: grantedByGroupId, + status: this.Status.ACTIVE + }) + + if (groupId) query.where({ group_id: groupId }) + if (productId) query.where({ product_id: productId }) + if (trackId) query.where({ track_id: trackId }) + if (groupRoleId) query.where({ group_role_id: groupRoleId }) + if (commonRoleId) query.where({ common_role_id: commonRoleId }) + + const access = await query.fetch() + + // Check if expired + if (access && access.isExpired()) { + // Update status to expired + await access.save({ status: this.Status.EXPIRED }) + return null + } + + return access + }, + + /** + * Get all active access records for a user granted by a specific group + * @param {String|Number} userId + * @param {String|Number} grantedByGroupId - Group granting the access + * @returns {Promise>} + */ + forUser: function (userId, grantedByGroupId) { + return this.where({ + user_id: userId, + granted_by_group_id: grantedByGroupId, + status: this.Status.ACTIVE + }).fetchAll() + }, + + /** + * Get all access records for a Stripe session + * (useful for finding all access grants from a bundle purchase) + * @param {String} sessionId - Stripe checkout session ID + * @returns {Promise>} + */ + forStripeSession: function (sessionId) { + return this.where({ stripe_session_id: sessionId }).fetchAll() + }, + + /** + * Extend access expiration date (for subscription renewals) + * @param {String|Number} accessId - ID of content_access record to extend + * @param {Date} newExpiresAt - New expiration date + * @param {Object} [extraMetadata] - Additional metadata to merge + * @param {Object} options - Options including transacting + * @returns {Promise} + */ + extendAccess: async function (accessId, newExpiresAt, extraMetadata = {}, { transacting } = {}) { + const access = await this.where({ id: accessId }).fetch({ transacting }) + if (!access) { + throw new Error(`Content access record not found: ${accessId}`) + } + + // Merge new metadata with existing + const existingMetadata = access.get('metadata') || {} + const updatedMetadata = { + ...existingMetadata, + ...extraMetadata, + last_renewed_at: new Date().toISOString() + } + + // Update expiration date and metadata + // Note: The database trigger will handle updating user_scopes.expires_at + return access.save({ + expires_at: newExpiresAt, + status: this.Status.ACTIVE, + metadata: updatedMetadata + }, { transacting }) + }, + + /** + * Find content access records by Stripe subscription ID + * Used for subscription renewals, cancellations, and refunds + * @param {String} subscriptionId - Stripe subscription ID + * @param {Object} options - Options including transacting + * @returns {Promise>} + */ + findBySubscriptionId: async function (subscriptionId, { transacting } = {}) { + return this.where({ stripe_subscription_id: subscriptionId }).fetchAll({ transacting }) + }, + + /** + * Check if an access record is subscription-based + * @param {ContentAccess} accessRecord - The access record to check + * @returns {Boolean} + */ + isSubscription: function (accessRecord) { + return !!accessRecord.get('stripe_subscription_id') + }, + + /** + * Get all active subscription-based access for a user + * @param {String|Number} userId + * @returns {Promise>} + */ + getActiveSubscriptions: function (userId) { + return this.query(qb => { + qb.where({ + user_id: userId, + status: this.Status.ACTIVE + }) + qb.whereNotNull('stripe_subscription_id') + }).fetchAll() + }, + + /** + * Send renewal reminder emails for subscriptions renewing in 7 days + * Called by daily cron job + * @returns {Promise} Number of reminders sent + */ + sendRenewalReminders: async function () { + /* global User, Group, StripeProduct, Frontend */ + + let remindersSent = 0 + const now = new Date() + const sixDaysFromNow = new Date(now.getTime() + 6 * 24 * 60 * 60 * 1000) + const eightDaysFromNow = new Date(now.getTime() + 8 * 24 * 60 * 60 * 1000) + + // Only query subscriptions where expires_at is in the 6-8 day window + // We rely on expires_at from our database, no need to check Stripe + const activeSubscriptions = await this.query(qb => { + qb.where({ + status: this.Status.ACTIVE + }) + qb.whereNotNull('stripe_subscription_id') + qb.whereBetween('expires_at', [sixDaysFromNow.toISOString(), eightDaysFromNow.toISOString()]) + }).fetchAll({ + withRelated: ['user', 'product', 'group', 'grantedByGroup'] + }) + + for (const access of activeSubscriptions.models) { + try { + const user = access.relations.user + const product = access.relations.product + const group = access.relations.grantedByGroup || access.relations.group + + if (!user || !product || !group) { + continue + } + + // Get expires_at from database (this is our renewal date) + const expiresAt = access.get('expires_at') + if (!expiresAt) { + continue + } + + const renewalDate = new Date(expiresAt) + + // Double-check it's within our window (should already be filtered by query, but safety check) + if (renewalDate < sixDaysFromNow || renewalDate > eightDaysFromNow) { + continue + } + + // Check if reminder was already sent for this expiration date + const metadata = access.get('metadata') || {} + const lastReminderSentAt = metadata.renewal_reminder_sent_at + const lastReminderExpiresAt = metadata.renewal_reminder_sent_for_expires_at + + // Skip if reminder already sent for this exact expiration date + if (lastReminderExpiresAt === expiresAt) { + continue + } + + // Skip if reminder was sent in the last 8 days (avoid re-checking too soon) + if (lastReminderSentAt) { + const lastSentDate = new Date(lastReminderSentAt) + const eightDaysAgo = new Date(now.getTime() - 8 * 24 * 60 * 60 * 1000) + if (lastSentDate > eightDaysAgo) { + continue + } + } + + // Get subscription details for email from database + const userLocale = user.getLocale() + const renewalDateFormatted = renewalDate.toLocaleDateString( + userLocale === 'es' ? 'es-ES' : 'en-US', + { + year: 'numeric', + month: 'long', + day: 'numeric' + } + ) + + // Get renewal amount and period from product + const priceInCents = product.get('price_in_cents') || 0 + const currency = product.get('currency') || 'usd' + const renewalAmount = StripeService.formatPrice(priceInCents, currency) + + // Map duration to renewal period + const duration = product.get('duration') + let renewalPeriod = null + if (duration === 'day') { + renewalPeriod = 'daily' + } else if (duration === 'month') { + renewalPeriod = 'monthly' + } else if (duration === 'season') { + renewalPeriod = 'quarterly' + } else if (duration === 'annual') { + renewalPeriod = 'annual' + } else if (duration) { + renewalPeriod = duration + } + + // Build email data + const emailData = { + user_name: user.get('name'), + offering_name: product.get('name'), + group_name: group.get('name'), + group_url: Frontend.Route.group(group), + renewal_date: renewalDateFormatted, + renewal_amount: renewalAmount, + renewal_period: renewalPeriod, + manage_subscription_url: `${process.env.FRONTEND_URL || 'https://hylo.com'}/settings/subscriptions`, + update_payment_url: `${process.env.FRONTEND_URL || 'https://hylo.com'}/settings/subscriptions`, + group_avatar_url: group.get('avatar_url') + } + + // Queue the email + Queue.classMethod('Email', 'sendSubscriptionRenewalReminder', { + email: user.get('email'), + data: emailData, + version: 'Redesign 2025', + locale: userLocale + }) + + // Mark reminder as sent in metadata using expires_at + const updatedMetadata = { + ...metadata, + renewal_reminder_sent_for_expires_at: expiresAt, + renewal_reminder_sent_at: new Date().toISOString() + } + + await access.save({ metadata: updatedMetadata }) + + remindersSent++ + + if (process.env.NODE_ENV === 'development') { + console.log(`Queued renewal reminder email for user ${user.id}, expires at ${expiresAt}`) + } + } catch (error) { + console.error(`Error sending renewal reminder for access ${access.id}:`, error) + // Continue with next subscription + } + } + + return remindersSent + }, + + /** + * Send expired access notification emails + * Called by daily cron job + * @returns {Promise} Number of notifications sent + */ + sendExpiredAccessNotifications: async function () { + /* global Track */ + + let notificationsSent = 0 + const now = new Date() + const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000) + + // Find access records that have expired recently (within the last week) + // This avoids constantly checking old expired records + // Query for records where expires_at is between one week ago and now + // (regardless of status - webhooks may have already changed it) + const expiredAccessRecords = await this.query(qb => { + qb.where('expires_at', '<=', now.toISOString()) + qb.where('expires_at', '>=', threeDaysAgo.toISOString()) + qb.whereNotNull('expires_at') + }).fetchAll({ + withRelated: ['user', 'product', 'group', 'grantedByGroup', 'track'] + }) + + for (const access of expiredAccessRecords.models) { + try { + // Skip if status is revoked (don't send expiration email for revoked access) + if (access.get('status') === this.Status.REVOKED) { + continue + } + + // If status is already expired, we still might need to send the email if we haven't sent it yet + // If status is still active, we'll update it to expired + + const currentExpiresAt = access.get('expires_at') + if (!currentExpiresAt) { + continue + } + + // Check if expiration email was already sent for this specific expiration date + // This handles renewals: if expires_at changes, we'll send again for the new expiration + const metadata = access.get('metadata') || {} + const lastSentForExpiresAt = metadata.expiration_email_sent_for_expires_at + + // Skip if we already sent for this exact expiration date + if (lastSentForExpiresAt === currentExpiresAt) { + continue + } + + // Double-check the access is actually expired (expires_at <= now) + const expiresAtDate = new Date(currentExpiresAt) + if (expiresAtDate > now) { + continue + } + + const user = access.relations.user + const product = access.relations.product + const group = access.relations.grantedByGroup || access.relations.group + const track = access.relations.track + + if (!user || !group) { + continue + } + + // Update status to expired (if not already) and mark email as sent for this expiration date + const updateData = { + metadata: { + ...metadata, + expiration_email_sent_for_expires_at: currentExpiresAt, + expiration_email_sent_at: new Date().toISOString() + } + } + + // Only update status if it's still active (webhooks may have already set it to expired) + if (access.get('status') === this.Status.ACTIVE) { + updateData.status = this.Status.EXPIRED + } + + await access.save(updateData) + + const userLocale = user.getLocale() + const expiresAt = new Date(access.get('expires_at')) + const expiredAtFormatted = expiresAt.toLocaleDateString( + userLocale === 'es' ? 'es-ES' : 'en-US', + { + year: 'numeric', + month: 'long', + day: 'numeric' + } + ) + + // Determine access type + const accessType = track ? 'track' : 'group' + + // Get available offerings for the group + const availableOfferings = [] + if (product) { + // Get all published offerings for this group + const offerings = await StripeProduct.where({ + group_id: group.id, + publish_status: 'published' + }).fetchAll() + + for (const offering of offerings.models) { + const priceInCents = offering.get('price_in_cents') || 0 + const currency = offering.get('currency') || 'usd' + const priceFormatted = StripeService.formatPrice(priceInCents, currency) + + availableOfferings.push({ + name: offering.get('name'), + price: priceFormatted + }) + } + } + + const emailData = { + user_name: user.get('name'), + access_type: accessType, + group_name: group.get('name'), + group_url: Frontend.Route.group(group), + expired_at: expiredAtFormatted, + renew_url: Frontend.Route.group(group), + available_offerings: availableOfferings, + group_avatar_url: group.get('avatar_url') + } + + // Add track info if applicable + if (track) { + emailData.track_name = track.get('name') + } + + Queue.classMethod('Email', 'sendAccessExpired', { + email: user.get('email'), + data: emailData, + version: 'Redesign 2025', + locale: userLocale + }) + + notificationsSent++ + + if (process.env.NODE_ENV === 'development') { + console.log(`Queued Access Expired email to user ${user.id} for access ${access.id}`) + } + } catch (error) { + console.error(`Error sending expired access notification for access ${access.id}:`, error) + // Continue with next access record + } + } + + return notificationsSent + } +}) diff --git a/apps/backend/api/models/Group.js b/apps/backend/api/models/Group.js index be30a90bd4..f34d4d659a 100644 --- a/apps/backend/api/models/Group.js +++ b/apps/backend/api/models/Group.js @@ -21,6 +21,7 @@ import { findOrCreateLocation } from '../graphql/mutations/location' import { whereId } from './group/queryUtils' import { es } from '../../lib/i18n/es' import { en } from '../../lib/i18n/en' +const { createGroupScope } = require('../../lib/scopes') const locales = { es, en } @@ -53,6 +54,52 @@ module.exports = bookshelf.Model.extend(merge({ return response }, + /** + * Builds a Stripe Dashboard URL for this group's connected account + */ + async stripeDashboardUrl () { + try { + const stripeAccountId = this.get('stripe_account_id') + if (!stripeAccountId) return null + const acct = await StripeAccount.where({ id: stripeAccountId }).fetch() + if (!acct) return null + const externalId = acct.get('stripe_account_external_id') + if (!externalId) return null + return `https://dashboard.stripe.com/${externalId}` + } catch (e) { + return null + } + }, + + /** + * Check if a user has access to this group + * If group has no paywall, returns true (free access) + * Otherwise checks: 1) full-access responsibilities, 2) scope-based access + * @param {String|Number} userId - User ID to check + * @returns {Promise} + */ + async canAccess (userId) { + // If no paywall, group is freely accessible + if (!this.get('paywall')) { + return true + } + + if (!userId) { + return false + } + + // Check if user has full-access responsibility (admin or content manager) + const groupId = this.get('id') + const hasFullAccess = await Group.hasFullAccessResponsibility(userId, groupId) + if (hasFullAccess) { + return true + } + + // Check scope-based access (purchased or granted) + const requiredScope = createGroupScope(groupId) + return await UserScope.canAccess(userId, requiredScope) + }, + // ******** Getters ******* // agreements: function () { @@ -732,7 +779,7 @@ module.exports = bookshelf.Model.extend(merge({ 'about_video_uri', 'active', 'access_code', 'accessibility', 'avatar_url', 'banner_url', 'description', 'geo_shape', 'location', 'location_id', 'name', 'purpose', 'settings', 'steward_descriptor', 'steward_descriptor_plural', 'type_descriptor', 'type_descriptor_plural', 'visibility', - 'welcome_page', 'website_url' + 'welcome_page', 'website_url', 'stripe_account_id', 'stripe_charges_enabled', 'stripe_payouts_enabled', 'stripe_details_submitted', 'paywall' ] const trimAttrs = ['name', 'description', 'purpose'] @@ -1365,6 +1412,32 @@ module.exports = bookshelf.Model.extend(merge({ return this.find(key, merge({ active: true }, opts)) }, + /** + * Check if a user has a responsibility that grants full access to group content + * Full-access responsibilities: Administration, Manage Content + * Limited responsibilities (no content access): Manage Rounds, Add Members, etc. + * + * @param {String|Number} userId - User ID to check + * @param {String|Number} groupId - Group ID to check + * @returns {Promise} + */ + hasFullAccessResponsibility: async function (userId, groupId) { + if (!userId || !groupId) { + return false + } + + // Get all responsibilities for this user in this group + const responsibilities = await Responsibility.fetchForUserAndGroupAsStrings(userId, groupId) + + // Check if user has any full-access responsibility + const fullAccessResponsibilities = [ + Responsibility.constants.RESP_ADMINISTRATION, + Responsibility.constants.RESP_MANAGE_CONTENT + ] + + return responsibilities.some(resp => fullAccessResponsibilities.includes(resp)) + }, + getNewAccessCode: function () { const test = code => Group.where({ access_code: code }).count().then(Number) const loop = () => { diff --git a/apps/backend/api/models/GroupMembership.js b/apps/backend/api/models/GroupMembership.js index 7018a7f5bb..2c5893dcd9 100644 --- a/apps/backend/api/models/GroupMembership.js +++ b/apps/backend/api/models/GroupMembership.js @@ -59,9 +59,17 @@ module.exports = bookshelf.Model.extend(Object.assign({ }, async acceptAgreements (transacting) { - this.addSetting({ agreementsAcceptedAt: (new Date()).toISOString() }) const groupId = this.get('group_id') const groupAgreements = await GroupAgreement.where({ group_id: groupId }).fetchAll({ transacting }) + + // Only set agreementsAcceptedAt if the group actually has agreements + // This prevents falsely recording acceptance when there's nothing to accept + if (groupAgreements.length === 0) { + return + } + + this.addSetting({ agreementsAcceptedAt: (new Date()).toISOString() }) + for (const ga of groupAgreements) { const attrs = { group_id: groupId, user_id: this.get('user_id'), agreement_id: ga.get('agreement_id') } await UserGroupAgreement @@ -75,6 +83,9 @@ module.exports = bookshelf.Model.extend(Object.assign({ } }) } + + // Save the membership to persist the agreementsAcceptedAt setting + await this.save(null, { transacting }) }, async updateAndSave (attrs, { transacting } = {}) { @@ -180,5 +191,109 @@ module.exports = bookshelf.Model.extend(Object.assign({ return membership } return false + }, + + /** + * Ensures a user is a member of a group + * Creates membership if it doesn't exist, or reactivates if inactive + * + * @param {User|Number} userOrId - User instance or user ID + * @param {Group|Number} groupOrId - Group instance or group ID + * @param {Object} [options] - Options + * @param {Number} [options.role] - Role to assign (defaults to DEFAULT) + * @param {Object} [options.transacting] - Database transaction + * @returns {Promise} The membership record + */ + async ensureMembership (userOrId, groupOrId, { role = GroupMembership.Role.DEFAULT, transacting } = {}) { + const userId = userOrId instanceof User ? userOrId.id : userOrId + const groupId = groupOrId instanceof Group ? groupOrId.id : groupOrId + + if (!userId) { + throw new Error("Can't call ensureMembership without a user or user id") + } + if (!groupId) { + throw new Error("Can't call ensureMembership without a group or group id") + } + + // Check for existing membership (including inactive) + const existingMembership = await GroupMembership.forPair(userId, groupId, { includeInactive: true }).fetch({ transacting }) + + if (existingMembership) { + // Membership exists + if (!existingMembership.get('active')) { + // Reactivate inactive membership + await existingMembership.save({ active: true }, { patch: true, transacting }) + } + return existingMembership + } + + // No membership exists, create it + const user = userOrId instanceof User ? userOrId : await User.find(userId, { transacting }) + if (!user) { + throw new Error(`User not found: ${userId}`) + } + + const group = groupOrId instanceof Group ? groupOrId : await Group.find(groupId, { transacting }) + if (!group) { + throw new Error(`Group not found: ${groupId}`) + } + + // Create membership via user.joinGroup + const membership = await user.joinGroup(group, { + role, + fromInvitation: true, // This will ensure join questions are still shown + transacting + }) + + return membership + }, + + /** + * Pin a group to the user's global navigation menu + * Adds it to the bottom of the pinned list + * + * @param {User|Number} userOrId - User instance or user ID + * @param {Group|Number} groupOrId - Group instance or group ID + * @param {Object} [options] - Options + * @param {Object} [options.transacting] - Database transaction + * @returns {Promise} The updated membership record + */ + async pinGroupToNav (userOrId, groupOrId, { transacting } = {}) { + const userId = userOrId instanceof User ? userOrId.id : userOrId + const groupId = groupOrId instanceof Group ? groupOrId.id : groupOrId + + if (!userId) { + throw new Error("Can't call pinGroupToNav without a user or user id") + } + if (!groupId) { + throw new Error("Can't call pinGroupToNav without a group or group id") + } + + const membership = await GroupMembership.forPair(userId, groupId).fetch({ transacting }) + if (!membership) { + throw new Error(`Membership not found for user ${userId} and group ${groupId}`) + } + + // Check if already pinned + if (membership.get('nav_order') !== null) { + // Already pinned, no need to do anything + return membership + } + + // Find the max nav_order for this user's pinned groups + const result = await bookshelf.knex('group_memberships') + .where({ user_id: userId }) + .whereNotNull('nav_order') + .max('nav_order as max_order') + .transacting(transacting) + .first() + + // Set this group's nav_order to max + 1 (or 0 if no pinned groups) + const maxOrder = result?.max_order + const newNavOrder = maxOrder !== null && maxOrder !== undefined ? maxOrder + 1 : 0 + + await membership.save({ nav_order: newNavOrder }, { patch: true, transacting }) + + return membership } }) diff --git a/apps/backend/api/models/GroupRole.js b/apps/backend/api/models/GroupRole.js index d57837756d..6b7adbeb6a 100644 --- a/apps/backend/api/models/GroupRole.js +++ b/apps/backend/api/models/GroupRole.js @@ -11,7 +11,59 @@ module.exports = bookshelf.Model.extend({ responsibilities: function () { return this.belongsToMany(Responsibility, 'group_roles_responsibilities', 'group_role_id', 'responsibility_id') + }, + + /** + * Get the scope strings that this role grants + * @returns {Array} Array of scope strings (e.g., ['group:123', 'track:456']) + */ + getScopes: function () { + const scopes = this.get('scopes') + // scopes is a JSONB array, return it or empty array if null + return scopes || [] + }, + + /** + * Set the scopes for this role + * Note: This will trigger database triggers to update user_scopes for all users with this role + * @param {Array} scopeStrings - Array of scope strings + * @param {Object} options - Options including transacting + * @returns {Promise} + */ + setScopes: async function (scopeStrings, { transacting } = {}) { + return this.save({ scopes: scopeStrings }, { transacting }) + }, + + /** + * Add a scope to this role + * @param {String} scopeString - Scope string to add + * @param {Object} options - Options including transacting + * @returns {Promise} + */ + addScope: async function (scopeString, { transacting } = {}) { + const currentScopes = this.getScopes() + if (!currentScopes.includes(scopeString)) { + currentScopes.push(scopeString) + return this.setScopes(currentScopes, { transacting }) + } + return this + }, + + /** + * Remove a scope from this role + * @param {String} scopeString - Scope string to remove + * @param {Object} options - Options including transacting + * @returns {Promise} + */ + removeScope: async function (scopeString, { transacting } = {}) { + const currentScopes = this.getScopes() + const filtered = currentScopes.filter(s => s !== scopeString) + if (filtered.length !== currentScopes.length) { + return this.setScopes(filtered, { transacting }) + } + return this } + }, { }) diff --git a/apps/backend/api/models/Invitation.js b/apps/backend/api/models/Invitation.js index 20422a6bca..8ee177a03e 100644 --- a/apps/backend/api/models/Invitation.js +++ b/apps/backend/api/models/Invitation.js @@ -26,6 +26,16 @@ module.exports = bookshelf.Model.extend(Object.assign({ return this.belongsTo(User, 'expired_by_id') }, + // The common role to assign when this invitation is used + commonRole: function () { + return this.belongsTo(CommonRole, 'common_role_id') + }, + + // The group-specific role to assign when this invitation is used + groupRole: function () { + return this.belongsTo(GroupRole, 'group_role_id') + }, + isUsed: function () { return !!this.get('used_by_id') }, @@ -51,6 +61,41 @@ module.exports = bookshelf.Model.extend(Object.assign({ await GroupMembership.forPair(user, group).fetch({ transacting }) || await user.joinGroup(group, { role, fromInvitation: true, transacting }) + // Assign common role if specified on the invitation + const commonRoleId = this.get('common_role_id') + if (commonRoleId) { + try { + await MemberCommonRole.forge({ + user_id: userId, + group_id: this.get('group_id'), + common_role_id: commonRoleId + }).save(null, { transacting }) + } catch (err) { + // Ignore duplicate key errors - user may already have this role + if (!err.message || !err.message.includes('duplicate key value')) { + throw err + } + } + } + + // Assign group-specific role if specified on the invitation + const groupRoleId = this.get('group_role_id') + if (groupRoleId) { + try { + await MemberGroupRole.forge({ + user_id: userId, + group_id: this.get('group_id'), + group_role_id: groupRoleId, + active: true + }).save(null, { transacting }) + } catch (err) { + // Ignore duplicate key errors - user may already have this role + if (!err.message || !err.message.includes('duplicate key value')) { + throw err + } + } + } + // TODO: we are not using this right now, but we could use to invite to a chat room if (!this.isUsed() && this.get('tag_id')) { try { @@ -132,6 +177,8 @@ module.exports = bookshelf.Model.extend(Object.assign({ email: opts.email.toLowerCase(), tag_id: opts.tagId, role: GroupMembership.Role[opts.moderator ? 'MODERATOR' : 'DEFAULT'], + common_role_id: opts.commonRoleId || null, // Common role to assign on join + group_role_id: opts.groupRoleId || null, // Group-specific role to assign on join token: uuidv4(), created_at: new Date(), subject: opts.subject, diff --git a/apps/backend/api/models/StripeProduct.js b/apps/backend/api/models/StripeProduct.js new file mode 100644 index 0000000000..2f58108716 --- /dev/null +++ b/apps/backend/api/models/StripeProduct.js @@ -0,0 +1,383 @@ +/* eslint-disable camelcase */ +/* global Track */ + +module.exports = bookshelf.Model.extend({ + tableName: 'stripe_products', + requireFetch: false, + hasTimestamps: true, + + /** + * The group that this Stripe product belongs to + */ + group: function () { + return this.belongsTo(Group, 'group_id') + }, + + /** + * Optional track that this product grants access to + * If set, purchasing this product grants access to the specific track + */ + track: function () { + return this.belongsTo(Track, 'track_id') + }, + + /** + * All content access records associated with this product + * (can be multiple users who purchased this product, or multiple access grants from a bundle) + */ + contentAccess: function () { + // Note: ContentAccess is globally available after models are loaded + return this.hasMany(ContentAccess, 'product_id') + }, + + /** + * Generate content access records from product definition + * + * Takes the access_grants JSONB field and creates individual content_access table records + * for each group/track/role combination defined in the product. + * + * @param {Object} params + * @param {String|Number} params.userId - User who purchased the product + * @param {String} params.sessionId - Stripe checkout session ID + * @param {String} [params.stripeSubscriptionId] - Stripe subscription ID (recurring purchases) + * @param {Date} [params.expiresAt] - When access expires + * @param {Object} [params.metadata] - Additional metadata + * @param {Object} options - Options including transacting + * @returns {Promise>} Array of created content access records + */ + generateContentAccessRecords: async function ({ + userId, + sessionId, + stripeSubscriptionId, + expiresAt, + metadata = {} + }, { transacting } = {}) { + const accessGrants = this.get('access_grants') || {} + const grantedByGroupId = this.get('group_id') // The group that owns/sells this product + const productId = this.get('id') + const duration = this.get('duration') + const accessRecords = [] + + // Ensure all IDs are integers (database expects bigint/integer types) + const userIdNum = parseInt(userId, 10) + const grantedByGroupIdNum = parseInt(grantedByGroupId, 10) + const productIdNum = parseInt(productId, 10) + + // Validate required IDs + if (isNaN(userIdNum) || userIdNum <= 0) { + throw new Error(`Invalid userId: ${userId}`) + } + if (isNaN(grantedByGroupIdNum) || grantedByGroupIdNum <= 0) { + throw new Error(`Invalid grantedByGroupId: ${grantedByGroupId}`) + } + if (isNaN(productIdNum) || productIdNum <= 0) { + throw new Error(`Invalid productId: ${productId}`) + } + + // Calculate expiration date if not provided + const calculatedExpiresAt = expiresAt || this.calculateExpirationDate(duration) + + // If access_grants is empty, grant basic group access + if (Object.keys(accessGrants).length === 0) { + const record = await ContentAccess.recordPurchase({ + userId: userIdNum, + grantedByGroupId: grantedByGroupIdNum, + productId: productIdNum, + sessionId, + stripeSubscriptionId, + expiresAt: calculatedExpiresAt, + metadata + }, { transacting }) + accessRecords.push(record) + return accessRecords + } + + // Process access_grants structure: { groupIds: [123, 456], trackIds: [1, 2], groupRoleIds: [3], commonRoleIds: [4] } + // Handle groupIds - create group access records + if (accessGrants.groupIds && Array.isArray(accessGrants.groupIds)) { + for (const groupId of accessGrants.groupIds) { + const groupIdNum = parseInt(groupId, 10) + if (isNaN(groupIdNum) || groupIdNum <= 0) { + console.warn(`Invalid groupId in access_grants.groupIds: ${groupId}, skipping`) + continue + } + + // Create base group access record + const baseRecord = await ContentAccess.recordPurchase({ + userId: userIdNum, + grantedByGroupId: grantedByGroupIdNum, + groupId: groupIdNum, + productId: productIdNum, + sessionId, + stripeSubscriptionId, + expiresAt: calculatedExpiresAt, + metadata: { + ...metadata, + accessType: 'group' + } + }, { transacting }) + accessRecords.push(baseRecord) + } + } + + // Handle trackIds - create track access records (applies to all groups in groupIds, or grantedByGroupId if no groupIds) + if (accessGrants.trackIds && Array.isArray(accessGrants.trackIds)) { + const groupIdsForTracks = accessGrants.groupIds && Array.isArray(accessGrants.groupIds) && accessGrants.groupIds.length > 0 + ? accessGrants.groupIds.map(id => parseInt(id, 10)).filter(id => !isNaN(id) && id > 0) + : [grantedByGroupIdNum] // Default to the group that owns the product + + for (const groupIdNum of groupIdsForTracks) { + for (const trackId of accessGrants.trackIds) { + // Convert trackId to integer or null + const trackIdNum = trackId != null ? parseInt(trackId, 10) : null + if (trackId != null && (isNaN(trackIdNum) || trackIdNum <= 0)) { + console.warn(`Invalid trackId: ${trackId}, skipping`) + continue + } + + const trackRecord = await ContentAccess.recordPurchase({ + userId: userIdNum, + grantedByGroupId: grantedByGroupIdNum, + groupId: groupIdNum, + productId: productIdNum, + trackId: trackIdNum, + sessionId, + stripeSubscriptionId, + expiresAt: calculatedExpiresAt, + metadata: { + ...metadata, + accessType: 'track' + } + }, { transacting }) + accessRecords.push(trackRecord) + + // Auto-enroll user in track when access is granted + try { + await Track.enroll(trackIdNum, userIdNum) + } catch (enrollError) { + // Log but don't fail the purchase if enrollment fails + console.warn(`Auto-enrollment in track ${trackIdNum} failed for user ${userIdNum}:`, enrollError.message) + } + } + } + } + + // Handle groupRoleIds - create role access records (applies to all groups in groupIds, or grantedByGroupId if no groupIds) + const groupIdsForRoles = accessGrants.groupIds && Array.isArray(accessGrants.groupIds) && accessGrants.groupIds.length > 0 + ? accessGrants.groupIds.map(id => parseInt(id, 10)).filter(id => !isNaN(id) && id > 0) + : [grantedByGroupIdNum] // Default to the group that owns the product + + if (accessGrants.groupRoleIds && Array.isArray(accessGrants.groupRoleIds)) { + /* global MemberGroupRole */ + for (const groupIdNum of groupIdsForRoles) { + for (const roleId of accessGrants.groupRoleIds) { + // Convert roleId to integer or null + const roleIdNum = roleId != null ? parseInt(roleId, 10) : null + if (roleId != null && (isNaN(roleIdNum) || roleIdNum <= 0)) { + console.warn(`Invalid groupRoleId: ${roleId}, skipping`) + continue + } + + // Check if the group role assignment already exists + const existing = await MemberGroupRole.where({ + user_id: userIdNum, + group_id: groupIdNum, + group_role_id: roleIdNum + }).fetch({ transacting }) + + if (!existing) { + // Create MemberGroupRole assignment + await MemberGroupRole.forge({ + user_id: userIdNum, + group_id: groupIdNum, + group_role_id: roleIdNum, + active: true + }).save(null, { transacting }) + } + + // Create content_access record to track the purchase + const roleRecord = await ContentAccess.recordPurchase({ + userId: userIdNum, + grantedByGroupId: grantedByGroupIdNum, + groupId: groupIdNum, + productId: productIdNum, + groupRoleId: roleIdNum, + sessionId, + stripeSubscriptionId, + expiresAt: calculatedExpiresAt, + metadata + }, { transacting }) + accessRecords.push(roleRecord) + } + } + } + + // Handle commonRoleIds - create MemberCommonRole records for common roles + // Common roles are assigned via group_memberships_common_roles table, not content_access + if (accessGrants.commonRoleIds && Array.isArray(accessGrants.commonRoleIds)) { + /* global MemberCommonRole */ + for (const groupIdNum of groupIdsForRoles) { + for (const commonRoleId of accessGrants.commonRoleIds) { + const commonRoleIdNum = commonRoleId != null ? parseInt(commonRoleId, 10) : null + if (commonRoleId != null && (isNaN(commonRoleIdNum) || commonRoleIdNum <= 0)) { + console.warn(`Invalid commonRoleId: ${commonRoleId}, skipping`) + continue + } + + // Check if the common role assignment already exists + const existing = await MemberCommonRole.where({ + user_id: userIdNum, + group_id: groupIdNum, + common_role_id: commonRoleIdNum + }).fetch({ transacting }) + + if (!existing) { + // Create MemberCommonRole assignment + await MemberCommonRole.forge({ + user_id: userIdNum, + group_id: groupIdNum, + common_role_id: commonRoleIdNum + }).save(null, { transacting }) + } + + // Also create a content_access record to track the purchase + const commonRoleRecord = await ContentAccess.recordPurchase({ + userId: userIdNum, + grantedByGroupId: grantedByGroupIdNum, + groupId: groupIdNum, + productId: productIdNum, + commonRoleId: commonRoleIdNum, + sessionId, + stripeSubscriptionId, + expiresAt: calculatedExpiresAt, + metadata + }, { transacting }) + accessRecords.push(commonRoleRecord) + } + } + } + + return accessRecords + }, + + /** + * Calculate expiration date based on duration + * @param {String} duration - Duration string (day, month, season, annual, lifetime, or null) + * @param {Date} [startDate] - Start date for calculation (defaults to now) + * @returns {Date|null} Expiration date or null for lifetime/no expiration + */ + calculateExpirationDate: function (duration, startDate = new Date()) { + if (!duration) { + return null // No expiration (lifetime or tracks) + } + + const start = new Date(startDate) + + // Use string literals to match Duration constants + switch (duration) { + case 'day': + return new Date(start.getTime() + (1 * 24 * 60 * 60 * 1000)) // 1 day (for testing) + + case 'month': + return new Date(start.getTime() + (30 * 24 * 60 * 60 * 1000)) // 30 days + + case 'season': + return new Date(start.getTime() + (90 * 24 * 60 * 60 * 1000)) // 90 days + + case 'annual': + return new Date(start.getTime() + (365 * 24 * 60 * 60 * 1000)) // 365 days + + case 'lifetime': + return null // No expiration + + default: + return null // Unknown duration, no expiration + } + } + +}, { + /** + * Create a new Stripe product record + * @param {Object} attrs - Product attributes (stripe_product_id, stripe_price_id, name, price_in_cents, etc.) + * @param {Object} attrs.access_grants - JSONB object defining what access this product grants + * @param {Object} options - Options including transacting + * @returns {Promise} + */ + create: async function (attrs, { transacting } = {}) { + // Set default values if not provided + const defaults = { + access_grants: {}, + renewal_policy: 'manual', + duration: null, + publish_status: 'unpublished' + } + return this.forge({ ...defaults, ...attrs }).save({}, { transacting }) + }, + + /** + * Find a product by its Stripe product ID + * @param {String} stripeProductId - The Stripe product ID + * @returns {Promise} + */ + findByStripeId: function (stripeProductId) { + return this.where({ stripe_product_id: stripeProductId }).fetch() + }, + + /** + * Get all published products for a group (excludes unpublished, unlisted, and archived) + * @param {String|Number} groupId - The group ID + * @returns {Promise>} + */ + forGroup: function (groupId) { + return this.where({ group_id: groupId, publish_status: 'published' }).fetchAll() + }, + + /** + * Get all published products for a specific track (excludes unpublished, unlisted, and archived) + * @param {String|Number} trackId - The track ID + * @returns {Promise>} + */ + forTrack: function (trackId) { + return this.where({ track_id: trackId, publish_status: 'published' }).fetchAll() + }, + + /** + * Update a Stripe product + * @param {String|Number} productId - The product ID + * @param {Object} attrs - Attributes to update + * @param {Object} options - Options including transacting + * @returns {Promise} + */ + update: async function (productId, attrs, { transacting } = {}) { + const product = await this.find(productId) + if (!product) { + throw new Error('Product not found') + } + + // Update the product with new attributes + return product.save(attrs, { transacting }) + }, + + // Renewal policy constants + RenewalPolicy: { + AUTOMATIC: 'automatic', + MANUAL: 'manual' + }, + + // Duration constants + Duration: { + DAY: 'day', // For testing subscription expiration in non-production environments + MONTH: 'month', + SEASON: 'season', + ANNUAL: 'annual', + LIFETIME: 'lifetime' + }, + + // Publish status constants + PublishStatus: { + UNPUBLISHED: 'unpublished', + UNLISTED: 'unlisted', + PUBLISHED: 'published', + ARCHIVED: 'archived' + } +}) diff --git a/apps/backend/api/models/Track.js b/apps/backend/api/models/Track.js index 98e3dd7197..fa8ea385c0 100644 --- a/apps/backend/api/models/Track.js +++ b/apps/backend/api/models/Track.js @@ -3,6 +3,7 @@ import { GraphQLError } from 'graphql' import HasSettings from './mixins/HasSettings' // TODO: does it have settings? import uniq from 'lodash/uniq' +const { createTrackScope } = require('../../lib/scopes') module.exports = bookshelf.Model.extend(Object.assign({ tableName: 'tracks', @@ -59,6 +60,38 @@ module.exports = bookshelf.Model.extend(Object.assign({ return this.trackUser(userId).fetch().then(trackUser => trackUser && trackUser.get('completed_at') !== null) }, + /** + * Check if a user has access to this track + * If track is not access controlled, returns true (free access) + * Otherwise checks: 1) full-access responsibilities in parent group, 2) scope-based access + * @param {String|Number} userId - User ID to check + * @returns {Promise} + */ + canAccess: async function (userId) { + // If track is not access controlled, it's freely accessible + if (!this.get('access_controlled')) { + return true + } + + if (!userId) { + return false + } + + // Check if user has full-access responsibility in parent group (admin or content manager) + const groupId = this.get('group_id') + if (groupId) { + const hasFullAccess = await Group.hasFullAccessResponsibility(userId, groupId) + if (hasFullAccess) { + return true + } + } + + // Check scope-based access (purchased or granted) + const trackId = this.get('id') + const requiredScope = createTrackScope(trackId) + return await UserScope.canAccess(userId, requiredScope) + }, + enrolledCount: function () { return this.trackUser().count() }, diff --git a/apps/backend/api/models/User.js b/apps/backend/api/models/User.js index 323fd76fdd..ae8b2dfdeb 100644 --- a/apps/backend/api/models/User.js +++ b/apps/backend/api/models/User.js @@ -380,8 +380,8 @@ module.exports = bookshelf.Model.extend(merge({ { role, settings: { - // XXX: A user choosing to join a group has aleady seen/filled out the join questions (enforced on the front-end) - joinQuestionsAnsweredAt: fromInvitation ? null : new Date(), + // Set joinQuestionsAnsweredAt if user answered questions during the join flow + joinQuestionsAnsweredAt: questionAnswers.length > 0 ? new Date() : null, postNotifications: 'all', digestFrequency: 'daily', sendEmail: true, diff --git a/apps/backend/api/models/UserScope.js b/apps/backend/api/models/UserScope.js new file mode 100644 index 0000000000..455d5feb14 --- /dev/null +++ b/apps/backend/api/models/UserScope.js @@ -0,0 +1,117 @@ +/* eslint-disable camelcase */ + +module.exports = bookshelf.Model.extend({ + tableName: 'user_scopes', + requireFetch: false, + hasTimestamps: true, + + user: function () { + return this.belongsTo(User, 'user_id') + }, + + /** + * Check if this scope is currently valid (not expired) + * @returns {Boolean} + */ + isValid: function () { + const expiresAt = this.get('expires_at') + return !expiresAt || new Date(expiresAt) > new Date() + } + +}, { + /** + * Check if a user has access to a specific scope + * This is the main access control function for the scope system + * + * @param {String|Number} userId - User ID to check + * @param {String} requiredScope - Scope string to check (e.g., 'group:123', 'track:456') + * @param {Date} [now] - Current time for expiration check (defaults to now) + * @returns {Promise} + */ + canAccess: async function (userId, requiredScope, now = new Date()) { + const result = await bookshelf.knex('user_scopes') + .where({ user_id: userId, scope: requiredScope }) + .where(function () { + this.whereNull('expires_at') + .orWhere('expires_at', '>', now) + }) + .first() + + return !!result + }, + + /** + * Get all valid scopes for a user + * @param {String|Number} userId - User ID + * @param {Date} [now] - Current time for expiration check (defaults to now) + * @returns {Promise>} Array of scope strings + */ + getUserScopes: async function (userId, now = new Date()) { + const results = await bookshelf.knex('user_scopes') + .where({ user_id: userId }) + .where(function () { + this.whereNull('expires_at') + .orWhere('expires_at', '>', now) + }) + .select('scope') + + return results.map(r => r.scope) + }, + + /** + * Get all scopes for a user of a specific type + * @param {String|Number} userId - User ID + * @param {String} scopeType - Scope type ('group', 'track', 'group_role') + * @param {Date} [now] - Current time for expiration check (defaults to now) + * @returns {Promise>} Array of scope strings + */ + getUserScopesByType: async function (userId, scopeType, now = new Date()) { + const results = await bookshelf.knex('user_scopes') + .where({ user_id: userId }) + .where('scope', 'like', `${scopeType}:%`) + .where(function () { + this.whereNull('expires_at') + .orWhere('expires_at', '>', now) + }) + .select('scope') + + return results.map(r => r.scope) + }, + + /** + * Get detailed scope information for a user + * @param {String|Number} userId - User ID + * @param {Date} [now] - Current time for expiration check (defaults to now) + * @returns {Promise>} Array of scope records with details + */ + getUserScopeDetails: async function (userId, now = new Date()) { + return await bookshelf.knex('user_scopes') + .where({ user_id: userId }) + .where(function () { + this.whereNull('expires_at') + .orWhere('expires_at', '>', now) + }) + .select('scope', 'expires_at', 'source_kind', 'source_id', 'created_at') + }, + + /** + * Check if a user has any of the provided scopes + * @param {String|Number} userId - User ID + * @param {Array} requiredScopes - Array of scope strings + * @param {Date} [now] - Current time for expiration check (defaults to now) + * @returns {Promise} + */ + hasAnyScope: async function (userId, requiredScopes, now = new Date()) { + const result = await bookshelf.knex('user_scopes') + .where({ user_id: userId }) + .whereIn('scope', requiredScopes) + .where(function () { + this.whereNull('expires_at') + .orWhere('expires_at', '>', now) + }) + .first() + + return !!result + } +}) + diff --git a/apps/backend/api/policies/checkAndSetMembership.js b/apps/backend/api/policies/checkAndSetMembership.js index 3e0f43b8a2..24e0d74594 100644 --- a/apps/backend/api/policies/checkAndSetMembership.js +++ b/apps/backend/api/policies/checkAndSetMembership.js @@ -16,8 +16,18 @@ module.exports = async function checkAndSetMembership (req, res, next) { const { userId } = req.session const membership = await GroupMembership.forPair(userId, group).fetch() - if (membership) return next() + if (!membership) { + sails.log.debug(`policy: checkAndSetMembership: no membership. user ${userId}, group ${group.id}`) + return res.forbidden() + } - sails.log.debug(`policy: checkAndSetMembership: fail. user ${req.session.userId}, group ${group.id}`) - res.forbidden() + // For paywalled groups, also check that user has proper scope access + // (membership alone is not enough - they need active content access) + const hasAccess = await group.canAccess(userId) + if (!hasAccess) { + sails.log.debug(`policy: checkAndSetMembership: paywall access denied. user ${userId}, group ${group.id}`) + return res.forbidden() + } + + return next() } diff --git a/apps/backend/api/services/Email.js b/apps/backend/api/services/Email.js index ea671eb9d9..2e43d2449f 100644 --- a/apps/backend/api/services/Email.js +++ b/apps/backend/api/services/Email.js @@ -34,16 +34,23 @@ const sendSimpleEmail = (address, templateId, data, extraOptions, locale = 'en-U locale: mapLocaleToSendWithUS(locale) }, extraOptions)) -const sendEmailWithOptions = curry((templateId, opts) => - sendEmail(merge({}, defaultOptions, { +const sendEmailWithOptions = curry((templateId, opts) => { + const emailOpts = merge({}, defaultOptions, { email_id: templateId, recipient: { address: opts.email }, email_data: opts.data, - version_name: opts.version, locale: mapLocaleToSendWithUS(opts.locale), sender: opts.sender, // expects {name, reply_to} files: opts.files - }))) + }) + + // Only include version_name if provided (SendWithUs will use most recent published version if not specified) + if (opts.version) { + emailOpts.version_name = opts.version + } + + return sendEmail(emailOpts) +}) module.exports = { sendSimpleEmail, @@ -114,6 +121,18 @@ module.exports = { sendFundingRoundPhaseTransitionEmail: sendEmailWithOptions('tem_8PBKqvdWGVXhq8hXpwwdRfSG'), sendFundingRoundReminderEmail: sendEmailWithOptions('tem_8PBKqvdWGVXhq8hXpwwdRfSG'), + // Paid content email templates + sendPurchaseConfirmation: sendEmailWithOptions('tem_9gQQRW8XgygjQpGGxQKYGdMS'), + sendAccessGranted: sendEmailWithOptions('tem_jfBqFPmhPP9jjfgSPB87YpDV'), + sendSubscriptionRenewalReminder: sendEmailWithOptions('tem_DrD9kmkKTkTCxTM7PhpW4jKf'), + sendSubscriptionRenewed: sendEmailWithOptions('tem_gvBCMVVxrCbt8S9cK98kYP9Q'), + sendPaymentFailed: sendEmailWithOptions('tem_YCXQrSjjqj8VqJWjhqHw66mF'), + sendSubscriptionCancelled: sendEmailWithOptions('tem_XfXjrYGdvDrPK4Sjprq7FtbS'), + sendSubscriptionCancelledAdminNotification: sendEmailWithOptions('tem_9ySxcvxKGKBXFQHJm4vS8cDC'), + sendAccessExpired: sendEmailWithOptions('tem_HVKwWYTMDbhWvvd3TGxtMkMG'), + sendDonationAcknowledgment: sendEmailWithOptions('tem_yXtGDQrJPb3Wdd8h4hpRV4yM'), + sendTrackAccessPurchased: sendEmailWithOptions('tem_T63TXtFjmyqhyrw8yfp6YwH8'), + sendMessageDigest: opts => sendEmailWithOptions('tem_xwQCfpdRT9K6hvrRFqDdhBRK', Object.assign({ version: 'Redesign 2025' }, opts)), diff --git a/apps/backend/api/services/InvitationService.js b/apps/backend/api/services/InvitationService.js index e5b63529c1..b86ec3d0a4 100644 --- a/apps/backend/api/services/InvitationService.js +++ b/apps/backend/api/services/InvitationService.js @@ -5,7 +5,7 @@ import { get, isEmpty, map, merge } from 'lodash/fp' module.exports = { checkPermission: (userId, invitationId) => { - return Invitation.find(invitationId, {withRelated: 'group'}) + return Invitation.find(invitationId, { withRelated: 'group' }) .then(async (invitation) => { if (!invitation) throw new GraphQLError('Invitation not found') const { group } = invitation.relations @@ -18,7 +18,7 @@ module.exports = { return Invitation.find(invitationId) }, - find: ({groupId, limit, offset, pendingOnly = false, includeExpired = false}) => { + find: ({ groupId, limit, offset, pendingOnly = false, includeExpired = false }) => { return Group.find(groupId) .then(group => Invitation.query(qb => { qb.limit(limit || 20) @@ -38,11 +38,11 @@ module.exports = { !includeExpired && qb.whereNull('expired_by_id') qb.orderBy('created_at', 'desc') - }).fetchAll({withRelated: ['user']})) + }).fetchAll({ withRelated: ['user'] })) .then(invitations => ({ total: invitations.length > 0 ? Number(invitations.first().get('total')) : 0, items: invitations.map(i => { - var user = i.relations.user + let user = i.relations.user if (isEmpty(user) && i.get('joined_user_id')) { user = { id: i.get('joined_user_id'), @@ -67,24 +67,28 @@ module.exports = { * @param message * @param isModerator {Boolean} should invite as moderator (defaults: false) * @param subject + * @param commonRoleId {Number} common role ID to assign when invitation is used + * @param groupRoleId {Number} group-specific role ID to assign when invitation is used */ - create: ({sessionUserId, groupId, tagName, userIds, emails = [], message, isModerator = false, subject}) => { + create: ({ sessionUserId, groupId, tagName, userIds, emails = [], message, isModerator = false, subject, commonRoleId, groupRoleId }) => { return Promise.join( userIds && User.query(q => q.whereIn('id', userIds)).fetchAll(), Group.find(groupId), tagName && Tag.find({ name: tagName }), (users, group, tag) => { - let concatenatedEmails = emails.concat(map(u => u.get('email'), get('models', users))) + const concatenatedEmails = emails.concat(map(u => u.get('email'), get('models', users))) return Promise.map(concatenatedEmails, email => { if (!validator.isEmail(email)) { - return {email, error: 'not a valid email address'} + return { email, error: 'not a valid email address' } } const opts = { email, userId: sessionUserId, - groupId: group.id + groupId: group.id, + commonRoleId: commonRoleId || null, + groupRoleId: groupRoleId || null } if (tag) { @@ -96,16 +100,16 @@ module.exports = { } return Invitation.create(opts) - .tap(i => i.refresh({withRelated: ['creator', 'group', 'tag']})) + .tap(i => i.refresh({ withRelated: ['creator', 'group', 'tag'] })) .then(invitation => { - return Queue.classMethod('Invitation', 'createAndSend', {invitation}) + return Queue.classMethod('Invitation', 'createAndSend', { invitation }) .then(() => ({ email, id: invitation.id, createdAt: invitation.created_at, lastSentAt: invitation.last_sent_at })) - .catch(err => ({email, error: err.message})) + .catch(err => ({ email, error: err.message })) }) }) }) @@ -120,7 +124,7 @@ module.exports = { * @param moderator {Boolean} should invite as moderator * @returns {*} */ - reinviteAll: ({sessionUserId, groupId, subject = '', message = '', isModerator = false}) => { + reinviteAll: ({ sessionUserId, groupId, subject = '', message = '', isModerator = false }) => { return Queue.classMethod('Invitation', 'reinviteAll', { groupId, subject, @@ -148,22 +152,64 @@ module.exports = { }) }, - check: (token, accessCode) => { + /** + * Check if an invitation is valid and return group information for redirect + * @param token {String} invitation token from email invite + * @param accessCode {String} access code from invite link + * @returns {Object} { valid, groupSlug, email, commonRole, groupRole } + */ + check: async (token, accessCode) => { if (accessCode) { - return Group.queryByAccessCode(accessCode) - .count() - .then(count => { - return {valid: count !== '0'} - }) + const group = await Group.queryByAccessCode(accessCode).fetch() + return { + valid: !!group, + groupSlug: group ? group.get('slug') : null } + } if (token) { - return Invitation.query() + const invitation = await Invitation.query() .where({ token, used_by_id: null, expired_by_id: null }) - .count() - .then(result => { - return { valid: result[0].count !== '0' } - }) + .first() + if (invitation) { + const group = await Group.find(invitation.group_id) + + // Load the common role if one is assigned to this invitation + let commonRole = null + if (invitation.common_role_id) { + commonRole = await CommonRole.where({ id: invitation.common_role_id }).fetch() + } + + // Load the group-specific role if one is assigned to this invitation + let groupRole = null + if (invitation.group_role_id) { + groupRole = await GroupRole.where({ id: invitation.group_role_id }).fetch() + } + + return { + valid: true, + groupSlug: group + ? group.get('slug') + : null, + email: invitation.email, + commonRole: commonRole + ? { + id: commonRole.id, + name: commonRole.get('name'), + emoji: commonRole.get('emoji') + } + : null, + groupRole: groupRole + ? { + id: groupRole.id, + name: groupRole.get('name'), + emoji: groupRole.get('emoji') + } + : null + } + } + return { valid: false } } + return { valid: false } }, async use (userId, token, accessCode) { @@ -172,6 +218,7 @@ module.exports = { return Group.queryByAccessCode(accessCode) .fetch() .then(group => { + // TODO STRIPE: We need to think through how invite links will be impacted by paywall return GroupMembership.forPair(user, group, { includeInactive: true }).fetch() .then(existingMembership => { if (existingMembership) { @@ -197,10 +244,11 @@ module.exports = { } if (token) { - return Invitation.where({token}).fetch() + return Invitation.where({ token }).fetch() .then(invitation => { if (!invitation) throw new GraphQLError('not found') if (invitation.isExpired()) throw new GraphQLError('expired') + // TODO STRIPE: We need to think through how invite links will be impacted by paywall return invitation.use(userId) }) } diff --git a/apps/backend/api/services/OfferingStatsService.js b/apps/backend/api/services/OfferingStatsService.js new file mode 100644 index 0000000000..4e081b3800 --- /dev/null +++ b/apps/backend/api/services/OfferingStatsService.js @@ -0,0 +1,398 @@ +/** + * OfferingStatsService + * + * Provides methods for calculating subscription statistics for offerings (StripeProducts). + * Handles active/lapsed subscriber counts and paginated subscriber lists. + * + * Revenue calculations require Stripe API integration (see StripeService). + */ + +/* global bookshelf, StripeProduct, Group, StripeAccount */ + +const StripeService = require('./StripeService') + +module.exports = { + /** + * Get subscription stats for an offering (StripeProduct) + * + * @param {String|Number} productId - The StripeProduct ID + * @returns {Promise} - { activeCount, lapsedCount } + */ + async getSubscriptionStats (productId) { + const activeCount = await this.getActiveSubscriberCount(productId) + const lapsedCount = await this.getLapsedSubscriberCount(productId) + + return { + activeCount, + lapsedCount + } + }, + + /** + * Get count of currently active subscribers for an offering + * + * @param {String|Number} productId - The StripeProduct ID + * @returns {Promise} - Count of active subscribers + */ + async getActiveSubscriberCount (productId) { + const result = await bookshelf.knex('content_access') + .where({ product_id: productId, status: 'active' }) + .andWhere(function () { + this.whereNull('expires_at').orWhere('expires_at', '>', new Date()) + }) + .count('* as count') + .first() + + return parseInt(result.count, 10) || 0 + }, + + /** + * Get count of lapsed (expired/revoked) subscribers for an offering + * + * @param {String|Number} productId - The StripeProduct ID + * @returns {Promise} - Count of lapsed subscribers + */ + async getLapsedSubscriberCount (productId) { + const result = await bookshelf.knex('content_access') + .where({ product_id: productId }) + .andWhere(function () { + this.whereIn('status', ['expired', 'revoked']) + .orWhere(function () { + this.where('status', 'active').andWhere('expires_at', '<', new Date()) + }) + }) + .count('* as count') + .first() + + return parseInt(result.count, 10) || 0 + }, + + /** + * Get paginated list of subscribers for an offering + * + * @param {String|Number} productId - The StripeProduct ID + * @param {Object} options - Pagination and filter options + * @param {Number} [options.page=1] - Page number (1-indexed) + * @param {Number} [options.pageSize=50] - Number of results per page + * @param {Boolean} [options.lapsedOnly=false] - If true, only return lapsed subscribers + * @returns {Promise} - { subscribers: Array, total: Number, page: Number, pageSize: Number, totalPages: Number } + */ + async getSubscribers (productId, { page = 1, pageSize = 50, lapsedOnly = false } = {}) { + const offset = (page - 1) * pageSize + + // Build base query + let query = bookshelf.knex('content_access') + .where({ product_id: productId }) + + // Apply filter for lapsed or active + if (lapsedOnly) { + query = query.andWhere(function () { + this.whereIn('status', ['expired', 'revoked']) + .orWhere(function () { + this.where('status', 'active').andWhere('expires_at', '<', new Date()) + }) + }) + } + + // Get total count first + const countResult = await query.clone().count('* as count').first() + const total = parseInt(countResult.count, 10) || 0 + + // Get paginated results with user data + const rows = await query.clone() + .select([ + 'content_access.id', + 'content_access.user_id', + 'content_access.status', + 'content_access.expires_at', + 'content_access.created_at', + 'content_access.stripe_subscription_id', + 'users.name as user_name', + 'users.avatar_url as user_avatar_url' + ]) + .join('users', 'content_access.user_id', 'users.id') + .orderBy('content_access.created_at', 'desc') + .limit(pageSize) + .offset(offset) + + // Transform rows into subscriber objects + const subscribers = rows.map(row => ({ + id: row.id, + userId: row.user_id, + userName: row.user_name, + userAvatarUrl: row.user_avatar_url, + status: this.determineSubscriberStatus(row), + joinedAt: row.created_at, + expiresAt: row.expires_at, + stripeSubscriptionId: row.stripe_subscription_id + })) + + return { + subscribers, + total, + page, + pageSize, + totalPages: Math.ceil(total / pageSize) + } + }, + + /** + * Determine the effective status of a subscriber + * Handles the case where status is 'active' but expires_at has passed + * + * @param {Object} row - Database row with status and expires_at + * @returns {String} - 'active' or 'lapsed' + */ + determineSubscriberStatus (row) { + if (row.status === 'active') { + // Check if expired + if (row.expires_at && new Date(row.expires_at) < new Date()) { + return 'lapsed' + } + return 'active' + } + // status is 'expired' or 'revoked' + return 'lapsed' + }, + + /** + * Get all active subscribers for an offering (no pagination) + * Useful for smaller offerings or export functionality + * + * @param {String|Number} productId - The StripeProduct ID + * @returns {Promise} - Array of subscriber objects + */ + async getAllActiveSubscribers (productId) { + const rows = await bookshelf.knex('content_access') + .where({ product_id: productId, status: 'active' }) + .andWhere(function () { + this.whereNull('expires_at').orWhere('expires_at', '>', new Date()) + }) + .select([ + 'content_access.id', + 'content_access.user_id', + 'content_access.stripe_subscription_id', + 'content_access.created_at', + 'content_access.expires_at' + ]) + .join('users', 'content_access.user_id', 'users.id') + .select([ + 'users.name as user_name', + 'users.avatar_url as user_avatar_url' + ]) + + return rows.map(row => ({ + id: row.id, + userId: row.user_id, + userName: row.user_name, + userAvatarUrl: row.user_avatar_url, + stripeSubscriptionId: row.stripe_subscription_id, + joinedAt: row.created_at, + expiresAt: row.expires_at + })) + }, + + /** + * Get Stripe subscription IDs for an offering's active subscribers + * Useful for calculating revenue from Stripe API + * + * @param {String|Number} productId - The StripeProduct ID + * @returns {Promise>} - Array of Stripe subscription IDs (excludes nulls) + */ + async getActiveStripeSubscriptionIds (productId) { + const rows = await bookshelf.knex('content_access') + .where({ product_id: productId, status: 'active' }) + .whereNotNull('stripe_subscription_id') + .andWhere(function () { + this.whereNull('expires_at').orWhere('expires_at', '>', new Date()) + }) + .select('stripe_subscription_id') + .distinct() + + return rows.map(row => row.stripe_subscription_id) + }, + + /** + * Calculate monthly revenue for an offering + * + * For subscription offerings (renewal_policy = 'automatic'): + * Fetches active subscriptions from Stripe and normalizes to monthly revenue. + * + * For one-time payment offerings (renewal_policy = 'manual'): + * Calculates the sum of purchases from the last 30 days based on content_access records. + * + * @param {String|Number} productId - The StripeProduct ID + * @returns {Promise} - { monthlyRevenueCents: Number, currency: String } + */ + async calculateMonthlyRevenue (productId) { + // Get the offering to find the group, price, and duration + const offering = await StripeProduct.where({ id: productId }).fetch() + if (!offering) { + return { monthlyRevenueCents: 0, currency: 'usd' } + } + + const duration = offering.get('duration') + + // Check if this is a subscription offering (has a recurring duration) + const isSubscription = ['annual', 'month', 'season'].includes(duration) + + // For one-time payments (no duration/lifetime), calculate from last 30 days of purchases + if (!isSubscription) { + return this.calculateOneTimePaymentRevenue(productId, offering) + } + + // For subscriptions, calculate from active Stripe subscriptions + return this.calculateSubscriptionRevenue(productId, offering) + }, + + /** + * Calculate revenue from one-time payment purchases in the last 30 days + * + * Uses content_access records to count purchases and multiplies by the offering price. + * Note: If the price has changed, this reflects the current price, not the historical purchase price. + * + * @param {String|Number} productId - The StripeProduct ID + * @param {Object} offering - The StripeProduct model instance + * @returns {Promise} - { monthlyRevenueCents: Number, currency: String } + */ + async calculateOneTimePaymentRevenue (productId, offering) { + const currency = offering.get('currency') || 'usd' + const priceInCents = offering.get('price_in_cents') || 0 + + // Calculate date 30 days ago + const thirtyDaysAgo = new Date() + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30) + + // Count purchases in the last 30 days + const result = await bookshelf.knex('content_access') + .where({ product_id: productId }) + .where('created_at', '>=', thirtyDaysAgo) + .whereNull('stripe_subscription_id') // One-time payments don't have subscription IDs + .count('* as count') + .first() + + const purchaseCount = parseInt(result.count, 10) || 0 + const monthlyRevenueCents = purchaseCount * priceInCents + + return { + monthlyRevenueCents, + currency + } + }, + + /** + * Calculate monthly revenue from active Stripe subscriptions + * + * Fetches active subscriptions from Stripe in a single API call and calculates + * the normalized monthly revenue by converting all billing intervals to monthly equivalents. + * + * @param {String|Number} productId - The StripeProduct ID + * @param {Object} offering - The StripeProduct model instance + * @returns {Promise} - { monthlyRevenueCents: Number, currency: String } + */ + async calculateSubscriptionRevenue (productId, offering) { + const groupId = offering.get('group_id') + const currency = offering.get('currency') || 'usd' + const stripePriceId = offering.get('stripe_price_id') + + // If no Stripe price ID, can't fetch from Stripe + if (!stripePriceId) { + return { monthlyRevenueCents: 0, currency } + } + + // Get the group to find the Stripe account + const group = await Group.where({ id: groupId }).fetch() + if (!group) { + return { monthlyRevenueCents: 0, currency } + } + + const stripeAccountId = group.get('stripe_account_id') + if (!stripeAccountId) { + return { monthlyRevenueCents: 0, currency } + } + + // Get the external account ID + const stripeAccount = await StripeAccount.where({ id: stripeAccountId }).fetch() + if (!stripeAccount) { + return { monthlyRevenueCents: 0, currency } + } + + const externalAccountId = stripeAccount.get('stripe_account_external_id') + if (!externalAccountId) { + return { monthlyRevenueCents: 0, currency } + } + + // Fetch all active subscriptions for this price in one API call + const subscriptions = await StripeService.listSubscriptionsByPrice( + externalAccountId, + stripePriceId, + { status: 'active' } + ) + + if (subscriptions.length === 0) { + return { monthlyRevenueCents: 0, currency } + } + + // Calculate monthly revenue from subscriptions + let monthlyRevenueCents = 0 + + for (const subscription of subscriptions) { + // Process each subscription item (usually just one) + for (const item of subscription.items.data) { + const price = item.price + if (!price || !price.unit_amount) continue + + const amountCents = price.unit_amount + const interval = price.recurring?.interval + const intervalCount = price.recurring?.interval_count || 1 + + // Normalize to monthly revenue + const monthlyAmount = this.normalizeToMonthly(amountCents, interval, intervalCount) + monthlyRevenueCents += monthlyAmount + } + } + + return { + monthlyRevenueCents: Math.round(monthlyRevenueCents), + currency + } + }, + + /** + * Normalize a subscription amount to monthly revenue + * + * @param {Number} amountCents - The subscription amount in cents + * @param {String} interval - The billing interval: 'day', 'week', 'month', 'year' + * @param {Number} intervalCount - Number of intervals between billings + * @returns {Number} - Monthly equivalent in cents + */ + normalizeToMonthly (amountCents, interval, intervalCount = 1) { + if (!interval || !amountCents) { + return 0 + } + + // Calculate months per billing cycle + let monthsPerCycle + switch (interval) { + case 'day': + // Approximate: 30.44 days per month + monthsPerCycle = (intervalCount / 30.44) + break + case 'week': + // Approximate: 4.35 weeks per month + monthsPerCycle = (intervalCount / 4.35) + break + case 'month': + monthsPerCycle = intervalCount + break + case 'year': + monthsPerCycle = intervalCount * 12 + break + default: + return 0 + } + + // Monthly revenue = amount / months per billing cycle + return amountCents / monthsPerCycle + } +} diff --git a/apps/backend/api/services/Search/util.js b/apps/backend/api/services/Search/util.js index 6bb06e7ddc..489b4f3696 100644 --- a/apps/backend/api/services/Search/util.js +++ b/apps/backend/api/services/Search/util.js @@ -243,3 +243,88 @@ export const filterAndSortGroups = curry((opts, q) => { q.orderBy(sortBy || 'name', order || sortBy === 'size' ? 'desc' : 'asc') }) + +export const filterAndSortContentAccess = curry((opts, q) => { + const { + groupIds, + search, + accessType, + status, + offeringId, + trackId, + groupRoleId, + commonRoleId, + sortBy = 'created_at', + order + } = opts + + // Filter by group IDs (groups that granted the access) + if (groupIds && groupIds.length > 0) { + q.whereIn('content_access.granted_by_group_id', groupIds) + } + + // Filter by user name search + if (search) { + q.join('users', 'users.id', '=', 'content_access.user_id') + q.whereRaw('users.name ilike ?', `%${search}%`) + } + + // Filter by access type + if (accessType) { + q.where('content_access.access_type', accessType) + } + + // Filter by status + if (status) { + q.where('content_access.status', status) + } + + // Filter by offering ID + if (offeringId) { + q.where('content_access.product_id', offeringId) + } + + // Filter by track ID + if (trackId) { + q.where('content_access.track_id', trackId) + } + + // Filter by group role ID + if (groupRoleId) { + q.where('content_access.group_role_id', groupRoleId) + } + + // Filter by common role ID + if (commonRoleId) { + q.where('content_access.common_role_id', commonRoleId) + } + + // Validate sortBy + const validSortColumns = { + created_at: 'content_access.created_at', + expires_at: 'content_access.expires_at', + user_name: 'users.name' + } + + const sortColumn = validSortColumns[sortBy] + if (!sortColumn) { + throw new GraphQLError(`Cannot sort by "${sortBy}"`) + } + + // Validate order + if (order && !['asc', 'desc'].includes(order.toLowerCase())) { + throw new GraphQLError(`Cannot use sort order "${order}"`) + } + + // If sorting by user name and not already joined, join users table + if (sortBy === 'user_name' && !search) { + q.join('users', 'users.id', '=', 'content_access.user_id') + } + + // Apply sorting + if (sortBy === 'user_name') { + q.orderByRaw(`lower("users"."name") ${order || 'asc'}`) + } else { + q.orderBy(sortColumn, order || 'desc') + } +}) diff --git a/apps/backend/api/services/StripeService.js b/apps/backend/api/services/StripeService.js new file mode 100644 index 0000000000..5cfee9e9d0 --- /dev/null +++ b/apps/backend/api/services/StripeService.js @@ -0,0 +1,1537 @@ +/** + * StripeService + * + * Service for managing Stripe Connect integration. + * Handles connected account creation, onboarding, product management, + * and payment processing for groups. + */ + +const Stripe = require('stripe') + +// Initialize Stripe with API version +// TODO STRIPE: Replace with your actual Stripe secret key +// Set this in your environment variables as STRIPE_SECRET_KEY +const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY + +// Fiscal sponsor's Stripe account ID (Hylo's connected account under fiscal sponsor) +// In production, donations are routed to this account for tax-deductible processing +// In non-production, donations stay in the platform account +const FISCAL_SPONSOR_ACCOUNT_ID = process.env.STRIPE_FISCAL_SPONSOR_ACCOUNT_ID + +// Cached donation price IDs per account (created programmatically on first use) +// Key: accountId (or 'platform' for platform account), Value: priceId +const cachedDonationPriceIds = {} + +// Validate that Stripe secret key is configured +if (!STRIPE_SECRET_KEY) { + throw new Error( + '🔴 STRIPE_SECRET_KEY environment variable is not set. ' + + 'Please add STRIPE_SECRET_KEY to your .env file or environment variables. ' + + 'You can find this in your Stripe Dashboard: https://dashboard.stripe.com/apikeys' + ) +} + +// Initialize Stripe client with the latest API version +// Note: API version should match what Stripe expects - check Stripe dashboard for latest +const stripe = new Stripe(STRIPE_SECRET_KEY, { + apiVersion: '2025-10-29.clover' // Updated to match Stripe's expected version +}) + +module.exports = { + + /** + * Ensures the Hylo donation product and price exist in Stripe. + * Creates them if they don't exist, otherwise retrieves the existing price ID. + * The price ID is cached per account for subsequent calls. + * + * @param {String} [accountId] - Connected account ID. If provided, creates on that account. + * @returns {Promise} The donation price ID + */ + async ensureDonationPriceExists (accountId = null) { + const cacheKey = accountId || 'platform' + + // Return cached price ID if we already have it for this account + if (cachedDonationPriceIds[cacheKey]) { + return cachedDonationPriceIds[cacheKey] + } + + try { + const DONATION_PRODUCT_NAME = 'Hylo Platform Donation' + const DONATION_PRODUCT_METADATA_KEY = 'hylo_donation_product' + + // Options for connected account API calls + const stripeOptions = accountId ? { stripeAccount: accountId } : {} + + // Search for existing donation product by metadata + // Note: products.search is not available on connected accounts, so we list and filter + let donationProduct = null + + if (accountId) { + // For connected accounts, list products and filter by metadata + const products = await stripe.products.list({ + active: true, + limit: 100 + }, stripeOptions) + + donationProduct = products.data.find( + p => p.metadata && p.metadata[DONATION_PRODUCT_METADATA_KEY] === 'true' + ) + } else { + // For platform account, use search + const existingProducts = await stripe.products.search({ + query: `metadata['${DONATION_PRODUCT_METADATA_KEY}']:'true' AND active:'true'` + }) + if (existingProducts.data.length > 0) { + donationProduct = existingProducts.data[0] + } + } + + if (donationProduct) { + if (process.env.NODE_ENV === 'development') { + console.log(`Found existing donation product on ${cacheKey}: ${donationProduct.id}`) + } + } else { + // Create the donation product + donationProduct = await stripe.products.create({ + name: DONATION_PRODUCT_NAME, + description: 'Tax-deductible donation to Hylo (501(c)(3) fiscally sponsored). Thank you for supporting the Hylo platform!', + metadata: { + [DONATION_PRODUCT_METADATA_KEY]: 'true' + } + }, stripeOptions) + if (process.env.NODE_ENV === 'development') { + console.log(`Created donation product on ${cacheKey}: ${donationProduct.id}`) + } + } + + // Search for existing $1 donation price + const existingPrices = await stripe.prices.list({ + product: donationProduct.id, + active: true, + limit: 10 + }, stripeOptions) + + // Find a $1 USD price + let donationPrice = existingPrices.data.find( + p => p.unit_amount === 100 && p.currency === 'usd' && !p.recurring + ) + + if (donationPrice) { + if (process.env.NODE_ENV === 'development') { + console.log(`Found existing donation price on ${cacheKey}: ${donationPrice.id}`) + } + } else { + // Create the $1 donation price + donationPrice = await stripe.prices.create({ + product: donationProduct.id, + unit_amount: 100, // $1.00 in cents + currency: 'usd', + metadata: { + hylo_donation_price: 'true' + } + }, stripeOptions) + if (process.env.NODE_ENV === 'development') { + console.log(`Created donation price on ${cacheKey}: ${donationPrice.id}`) + } + } + + // Cache the price ID for this account + cachedDonationPriceIds[cacheKey] = donationPrice.id + return cachedDonationPriceIds[cacheKey] + } catch (error) { + console.error('Error ensuring donation price exists:', error) + throw new Error(`Failed to ensure donation price exists: ${error.message}`) + } + }, + + /** + * Creates a new Stripe Connected Account for a group + * + * This allows groups to receive payments directly while the platform + * takes an application fee. The account is created with: + * - Connected account pays Stripe fees + * - Stripe handles payment disputes and losses + * - Connected account gets full dashboard access + * - Group ID stored in metadata for webhook correlation + * + * @param {Object} params - Account creation parameters + * @param {String} params.email - Email address for the connected account + * @param {String} params.country - Two-letter country code (e.g. 'US', 'GB') + * @param {String} params.businessName - Business/group name + * @param {String} params.groupId - Group ID for metadata correlation + * @returns {Promise} The created Stripe account object + */ + async createConnectedAccount ({ email, country = 'US', businessName, groupId }) { + try { + // Validate required parameters + if (!email) { + throw new Error('Email is required to create a connected account') + } + + if (!businessName) { + throw new Error('Business name is required to create a connected account') + } + + if (!groupId) { + throw new Error('Group ID is required for account metadata') + } + + // Create the connected account with controller settings + // Note: We use controller properties, NOT top-level type property + const account = await stripe.accounts.create({ + email, + country, + business_profile: { + name: businessName + }, + metadata: { + group_id: groupId.toString(), + platform: 'hylo' + }, + controller: { + // Platform controls fee collection - connected account pays fees + fees: { + payer: 'account' + }, + // Stripe handles payment disputes and losses + losses: { + payments: 'stripe' + }, + // Connected account gets full access to Stripe dashboard + stripe_dashboard: { + type: 'full' + } + } + }) + + return account + } catch (error) { + console.error('Error creating connected account:', error) + throw new Error(`Failed to create Stripe connected account: ${error.message}`) + } + }, + + /** + * Connects an existing Stripe account to a group + * + * This allows groups to use their existing Stripe account instead of + * creating a new one. The account can be connected even if not fully + * verified - verification status will be displayed in the UI and + * prevent product publishing until the account is ready. + * + * NOTE: This only works for Stripe accounts that were originally created + * through our platform (connected accounts). To connect accounts created + * outside our platform, we need to implement Stripe OAuth flow. + * + * Note: Stripe will handle the edge case where the user already has an account + * by prompting them to connect their existing account during onboarding. + * + * @param {Object} params - Connection parameters + * @param {String} params.accountId - Existing Stripe account ID (must be a connected account) + * @param {String} params.groupId - Group ID for metadata correlation + * @returns {Promise} The Stripe account object + */ + async connectExistingAccount ({ accountId, groupId }) { + try { + // Validate required parameters + if (!accountId) { + throw new Error('Account ID is required to connect existing account') + } + + if (!groupId) { + throw new Error('Group ID is required for account metadata') + } + + // Validate that the account exists by retrieving it + // This will throw an error if the account ID is invalid + // Note: For connected accounts, we retrieve using the account ID directly + // The accountId should be a string like 'acct_xxx' + if (process.env.NODE_ENV !== 'production') { + console.log('Attempting to retrieve Stripe account:', { + accountId, + accountIdType: typeof accountId, + accountIdLength: accountId?.length, + isString: typeof accountId === 'string' + }) + } + // Verify account exists (will throw if not found) + await stripe.accounts.retrieve(accountId) + + // Account exists and is valid - we can proceed with connection + // No need to check verification status here as UI will handle that + + // Update the account metadata to include our group ID + const updatedAccount = await stripe.accounts.update(accountId, { + metadata: { + group_id: groupId.toString(), + platform: 'hylo', + connected_at: new Date().toISOString() + } + }) + + return updatedAccount + } catch (error) { + console.error('Error connecting existing account:', { + accountId, + groupId, + errorType: error.type, + errorMessage: error.message, + errorCode: error.code, + errorParam: error.param, + errorDetail: error.detail, + rawError: error.raw, + fullError: error + }) + if (error.type === 'StripeInvalidRequestError') { + // Provide more specific error message based on the error details + if (error.message && error.message.includes('API version')) { + throw new Error(`Stripe API version error: ${error.message}. This may indicate an issue with the Stripe SDK configuration.`) + } + throw new Error(`Invalid Stripe account ID provided: ${error.message}`) + } + throw new Error(`Failed to connect existing Stripe account: ${error.message}`) + } + }, + + /** + * Creates an Account Link for onboarding a connected account + * + * Account Links are temporary URLs that allow connected accounts + * to complete their onboarding and gain access to the Stripe Dashboard. + * Links expire after the specified time. + * + * @param {Object} params - Account link parameters + * @param {String} params.accountId - The Stripe connected account ID + * @param {String} params.refreshUrl - URL to redirect if link expires + * @param {String} params.returnUrl - URL to redirect after onboarding complete + * @returns {Promise} Account link object with url property + */ + async createAccountLink ({ accountId, refreshUrl, returnUrl }) { + try { + // Validate required parameters + if (!accountId) { + throw new Error('Account ID is required to create an account link') + } + + if (!refreshUrl || !returnUrl) { + throw new Error('Both refreshUrl and returnUrl are required') + } + + // Create the account link for onboarding + const accountLink = await stripe.accountLinks.create({ + account: accountId, + refresh_url: refreshUrl, + return_url: returnUrl, + type: 'account_onboarding' + }) + + return accountLink + } catch (error) { + console.error('Error creating account link:', error) + throw new Error(`Failed to create account link: ${error.message}`) + } + }, + + /** + * Retrieves the current status of a connected account + * + * This fetches the account directly from Stripe to get the most + * up-to-date onboarding status and capabilities. + * + * @param {String} accountId - The Stripe connected account ID + * @returns {Promise} Account object with status information + */ + async getAccountStatus (accountId) { + try { + // Validate required parameter + if (!accountId) { + throw new Error('Account ID is required to get account status') + } + + // Retrieve the account from Stripe + const account = await stripe.accounts.retrieve(accountId) + + // Return relevant status information + return { + id: account.id, + charges_enabled: account.charges_enabled, + payouts_enabled: account.payouts_enabled, + details_submitted: account.details_submitted, + requirements: account.requirements, + email: account.email, + business_profile: account.business_profile + } + } catch (error) { + console.error('Error retrieving account status:', error) + throw new Error(`Failed to retrieve account status: ${error.message}`) + } + }, + + /** + * Creates a product on a connected account + * + * Products represent the items/memberships/content that the + * connected account is selling. Uses the Stripe-Account header + * to create the product on the connected account, not the platform. + * + * @param {Object} params - Product creation parameters + * @param {String} params.accountId - The Stripe connected account ID + * @param {String} params.name - Product name + * @param {String} params.description - Product description + * @param {Number} params.priceInCents - Price in cents (e.g. 2000 = $20.00) + * @param {String} params.currency - Three-letter currency code (e.g. 'usd') + * @param {String} params.billingInterval - Optional: 'month', 'year', or null for one-time + * @param {Number} params.billingIntervalCount - Optional: number of intervals (e.g., 3 for quarterly) + * @returns {Promise} The created product with default price + */ + async createProduct ({ accountId, name, description, priceInCents, currency = 'usd', billingInterval = null, billingIntervalCount = 1 }) { + try { + // Validate required parameters + if (!accountId) { + throw new Error('Account ID is required to create a product') + } + + if (!name) { + throw new Error('Product name is required') + } + + if (priceInCents === undefined || priceInCents === null) { + throw new Error('Price is required') + } + + if (priceInCents < 0) { + throw new Error('Price must be a positive number') + } + + // Build price data - add recurring if billingInterval specified + const priceData = { + unit_amount: priceInCents, + currency: currency.toLowerCase() + } + + if (billingInterval) { + priceData.recurring = { + interval: billingInterval, // 'day', 'week', 'month', or 'year' + interval_count: billingIntervalCount // e.g., 3 for quarterly (every 3 months) + } + } + + // Create product on the connected account using stripeAccount parameter + const product = await stripe.products.create({ + name, + description: description || '', + default_price_data: priceData + }, { + stripeAccount: accountId // This header creates the product on the connected account + }) + + return product + } catch (error) { + console.error('Error creating product:', error) + throw new Error(`Failed to create product: ${error.message}`) + } + }, + + /** + * Updates a product on a connected account + * + * Updates product details in Stripe and returns the updated product. + * Only updates fields that are provided and different from current values. + * + * @param {Object} params - Product update parameters + * @param {String} params.accountId - The Stripe connected account ID + * @param {String} params.productId - The Stripe product ID to update + * @param {String} [params.name] - New product name + * @param {String} [params.description] - New product description + * @param {Number} [params.priceInCents] - New price in cents + * @param {String} [params.currency] - New currency code + * @returns {Promise} The updated product object + */ + async updateProduct ({ accountId, productId, name, description, priceInCents, currency }) { + try { + // Validate required parameters + if (!accountId) { + throw new Error('Account ID is required to update a product') + } + + if (!productId) { + throw new Error('Product ID is required') + } + + // First, get the current product to compare values + const currentProduct = await stripe.products.retrieve(productId, { + expand: ['default_price'] + }, { + stripeAccount: accountId + }) + + const updateData = {} + + // Only update fields that are provided and different + if (name !== undefined && name !== currentProduct.name) { + updateData.name = name + } + + if (description !== undefined && description !== currentProduct.description) { + updateData.description = description || '' + } + + // Handle price updates - this requires updating the price, not the product + if (priceInCents !== undefined || currency !== undefined) { + const currentPrice = currentProduct.default_price + const newPriceInCents = priceInCents !== undefined ? priceInCents : currentPrice.unit_amount + const newCurrency = currency !== undefined ? currency.toLowerCase() : currentPrice.currency + + // Only update price if it's different + if (newPriceInCents !== currentPrice.unit_amount || newCurrency !== currentPrice.currency) { + // Create a new price for the product + const newPrice = await stripe.prices.create({ + product: productId, + unit_amount: newPriceInCents, + currency: newCurrency + }, { + stripeAccount: accountId + }) + + // Update the product to use the new default price + updateData.default_price = newPrice.id + } + } + + // Only proceed with update if there are changes + if (Object.keys(updateData).length === 0) { + return currentProduct // No changes needed + } + + // Update the product in Stripe + const updatedProduct = await stripe.products.update(productId, updateData, { + stripeAccount: accountId + }) + + // If we created a new price, retrieve the product with expanded price info + if (updateData.default_price) { + return await stripe.products.retrieve(productId, { + expand: ['default_price'] + }, { + stripeAccount: accountId + }) + } + + return updatedProduct + } catch (error) { + console.error('Error updating product:', error) + throw new Error(`Failed to update product: ${error.message}`) + } + }, + + /** + * Retrieves all products for a connected account + * + * Lists products using the Stripe-Account header to get products + * from the connected account, not the platform account. + * + * @param {String} accountId - The Stripe connected account ID + * @param {Object} options - Optional pagination parameters + * @param {Number} options.limit - Number of products to retrieve (default: 100) + * @returns {Promise} Array of product objects + */ + async getProducts (accountId, { limit = 100 } = {}) { + try { + // Validate required parameter + if (!accountId) { + throw new Error('Account ID is required to retrieve products') + } + + // List products from the connected account + const products = await stripe.products.list({ + limit, + active: true // Only return active products + }, { + stripeAccount: accountId // This header retrieves from the connected account + }) + + return products.data + } catch (error) { + console.error('Error retrieving products:', error) + throw new Error(`Failed to retrieve products: ${error.message}`) + } + }, + + /** + * Retrieves a single product with its price information + * + * Fetches a product and expands the default price to get + * pricing information in a single call. + * + * @param {String} accountId - The Stripe connected account ID + * @param {String} productId - The product ID to retrieve + * @returns {Promise} Product object with expanded price + */ + async getProduct (accountId, productId) { + try { + // Validate required parameters + if (!accountId) { + throw new Error('Account ID is required to retrieve a product') + } + + if (!productId) { + throw new Error('Product ID is required') + } + + // Retrieve the product with expanded price information + const product = await stripe.products.retrieve(productId, { + expand: ['default_price'] + }, { + stripeAccount: accountId + }) + + return product + } catch (error) { + console.error('Error retrieving product:', error) + throw new Error(`Failed to retrieve product: ${error.message}`) + } + }, + + /** + * Retrieves a price object from Stripe + * + * Fetches detailed price information including unit amount and currency. + * + * @param {String} accountId - The Stripe connected account ID + * @param {String} priceId - The price ID to retrieve + * @returns {Promise} Price object with unit_amount, currency, etc. + */ + async getPrice (accountId, priceId) { + try { + // Validate required parameters + if (!accountId) { + throw new Error('Account ID is required to retrieve a price') + } + + if (!priceId) { + throw new Error('Price ID is required') + } + + // Retrieve the price from the connected account + const price = await stripe.prices.retrieve(priceId, { + stripeAccount: accountId + }) + + return price + } catch (error) { + console.error('Error retrieving price:', error) + throw new Error(`Failed to retrieve price: ${error.message}`) + } + }, + + /** + * Creates a Checkout Session for purchasing a product + * + * Uses Stripe Hosted Checkout for a secure payment experience. + * Implements Direct Charges with an application fee to monetize + * the transaction. The platform takes a fee, and the rest goes + * to the connected account. + * + * Includes an optional donation to Hylo that appears on the Stripe checkout page. + * + * @param {Object} params - Checkout session parameters + * @param {String} params.accountId - The Stripe connected account ID + * @param {String} params.priceId - The price ID to charge + * @param {Number} params.quantity - Quantity to purchase + * @param {Number} params.applicationFeeAmount - Platform fee in cents + * @param {String} params.successUrl - URL to redirect on success + * @param {String} params.cancelUrl - URL to redirect on cancel + * @param {String} params.mode - Checkout mode: 'payment' or 'subscription' + * @param {Object} params.metadata - Optional metadata to attach + * @returns {Promise} Checkout session with url to redirect customer + */ + async createCheckoutSession ({ + accountId, + priceId, + quantity = 1, + applicationFeeAmount, + successUrl, + cancelUrl, + mode = 'payment', + metadata = {} + }) { + try { + // Validate required parameters + if (!accountId) { + throw new Error('Account ID is required to create a checkout session') + } + + if (!priceId) { + throw new Error('Price ID is required') + } + + if (!applicationFeeAmount || applicationFeeAmount < 0) { + throw new Error('Valid application fee amount is required') + } + + if (!successUrl || !cancelUrl) { + throw new Error('Both success and cancel URLs are required') + } + + // Get currency and recurring info from the price to match donation configuration + const priceObject = await this.getPrice(accountId, priceId) + const currency = priceObject.currency || 'usd' + const isRecurring = mode === 'subscription' && priceObject.recurring + + // Build session configuration based on mode + const sessionConfig = { + line_items: [{ + price: priceId, + quantity + }], + mode, + success_url: successUrl, + cancel_url: cancelUrl, + metadata + } + + // Add optional donation to checkout session + // Uses a pre-created price ID (created programmatically on the connected account if it doesn't exist) + // Note: Recurring donations are not yet supported - only one-time donations for now + try { + const donationPriceId = await this.ensureDonationPriceExists(accountId) + + // Add one-time donation option (works for both subscription and payment modes) + // User can select quantity 0-100 ($0 to $100 in $1 increments) + // Note: quantity must be >= 1 for API, but adjustable_quantity.minimum: 0 + // allows users to reduce it to 0 in the checkout UI + // ALSO: its completely optional - users can checkout without it if they want + sessionConfig.optional_items = [ + { + price: donationPriceId, + quantity: 5, // This displays as an add-on + adjustable_quantity: { + enabled: true, + minimum: 0, + maximum: 100 + } + } + ] + + // Store flag in metadata to indicate donation option was available + metadata.hasDonationOption = 'true' + } catch (donationError) { + // If donation setup fails, continue without it - don't block the checkout + console.error('Failed to set up donation option, continuing without it:', donationError.message) + } + + // Configure for payment or subscription mode + if (mode === 'subscription') { + // For subscriptions, use subscription_data with platform fee + sessionConfig.subscription_data = { + application_fee_percent: 7.0, // Platform takes 7% of each subscription payment + metadata // Metadata flows to subscription + } + } else { + // For one-time payments, use payment_intent_data + sessionConfig.payment_intent_data = { + application_fee_amount: applicationFeeAmount, + metadata: { + session_id: 'placeholder' // Will be updated after session creation + } + } + } + + // Create checkout session on the connected account + const session = await stripe.checkout.sessions.create(sessionConfig, { + stripeAccount: accountId // Process payment on connected account + }) + + // For one-time payments, update payment intent with session ID for refund tracking + if (mode === 'payment' && session.payment_intent) { + await stripe.paymentIntents.update(session.payment_intent, { + metadata: { + session_id: session.id + } + }, { + stripeAccount: accountId + }) + } + + // For subscriptions, update subscription with session ID + if (mode === 'subscription' && session.subscription) { + await stripe.subscriptions.update(session.subscription, { + metadata: { + ...metadata, + sessionId: session.id + } + }, { + stripeAccount: accountId + }) + } + + return session + } catch (error) { + console.error('Error creating checkout session:', error) + throw new Error(`Failed to create checkout session: ${error.message}`) + } + }, + + /** + * Retrieves a checkout session + * + * Used to verify payment status after customer returns from + * Stripe Hosted Checkout. + * + * @param {String} accountId - The Stripe connected account ID + * @param {String} sessionId - The checkout session ID + * @returns {Promise} Checkout session object + */ + async getCheckoutSession (accountId, sessionId) { + try { + // Validate required parameters + if (!accountId) { + throw new Error('Account ID is required') + } + + if (!sessionId) { + throw new Error('Session ID is required') + } + + // Retrieve the checkout session + const session = await stripe.checkout.sessions.retrieve(sessionId, { + expand: ['payment_intent', 'line_items'] + }, { + stripeAccount: accountId + }) + + return session + } catch (error) { + console.error('Error retrieving checkout session:', error) + throw new Error(`Failed to retrieve checkout session: ${error.message}`) + } + }, + + /** + * Retrieves an invoice from Stripe with expanded payment details + * + * @param {String} accountId - The Stripe connected account ID + * @param {String} invoiceId - The invoice ID to retrieve + * @returns {Promise} Invoice object with expanded payment_intent and charge + */ + async getInvoice (accountId, invoiceId) { + try { + if (!accountId) { + throw new Error('Account ID is required') + } + + if (!invoiceId) { + throw new Error('Invoice ID is required') + } + + const invoice = await stripe.invoices.retrieve(invoiceId, { + expand: ['payment_intent', 'charge'] + }, { + stripeAccount: accountId + }) + + return invoice + } catch (error) { + console.error('Error retrieving invoice:', error) + throw new Error(`Failed to retrieve invoice: ${error.message}`) + } + }, + + /** + * Lists all subscriptions for a specific price on a connected account + * + * Fetches all subscriptions in one API call, filtered by price ID. + * Much more efficient than fetching individual subscriptions. + * + * @param {String} accountId - The Stripe connected account ID + * @param {String} priceId - The price ID to filter by + * @param {String} status - Filter by status: 'active', 'canceled', 'all', etc. (default: 'active') + * @param {Number} limit - Maximum number of subscriptions to return (default: 100) + * @returns {Promise>} Array of subscription objects + */ + async listSubscriptionsByPrice (accountId, priceId, { status = 'active', limit = 100 } = {}) { + try { + if (!accountId) { + throw new Error('Account ID is required to list subscriptions') + } + + if (!priceId) { + throw new Error('Price ID is required to list subscriptions') + } + + const params = { + price: priceId, + limit, + expand: ['data.items.data.price'] + } + + // Only add status filter if not 'all' + if (status !== 'all') { + params.status = status + } + + const subscriptions = await stripe.subscriptions.list(params, { + stripeAccount: accountId + }) + + return subscriptions.data + } catch (error) { + console.error('Error listing subscriptions by price:', error) + throw new Error(`Failed to list subscriptions: ${error.message}`) + } + }, + + /** + * Retrieves a subscription from Stripe + * + * Used to get subscription details for individual lookups. + * Expands the items to get price information. + * + * @param {String} accountId - The Stripe connected account ID + * @param {String} subscriptionId - The subscription ID to retrieve + * @returns {Promise} Subscription object with expanded items + */ + async getSubscription (accountId, subscriptionId) { + try { + // Validate required parameters + if (!accountId) { + throw new Error('Account ID is required to retrieve a subscription') + } + + if (!subscriptionId) { + throw new Error('Subscription ID is required') + } + + // Retrieve the subscription with expanded items to get price info + const subscription = await stripe.subscriptions.retrieve(subscriptionId, { + expand: ['items.data.price'] + }, { + stripeAccount: accountId + }) + + return subscription + } catch (error) { + console.error('Error retrieving subscription:', error) + throw new Error(`Failed to retrieve subscription: ${error.message}`) + } + }, + + /** + * Retrieves multiple subscriptions from Stripe + * + * Fetches multiple subscriptions in a single batch for efficiency. + * Returns an array of subscription objects. + * + * @param {String} accountId - The Stripe connected account ID + * @param {Array} subscriptionIds - Array of subscription IDs to retrieve + * @returns {Promise>} Array of subscription objects + */ + async getSubscriptions (accountId, subscriptionIds) { + if (!accountId || !subscriptionIds || subscriptionIds.length === 0) { + return [] + } + + // Fetch subscriptions in parallel with error handling for individual failures + const results = await Promise.allSettled( + subscriptionIds.map(subId => this.getSubscription(accountId, subId)) + ) + + // Return only successful results + return results + .filter(r => r.status === 'fulfilled') + .map(r => r.value) + }, + + /** + * Creates a Billing Portal session for a customer + * + * Generates a URL where customers can manage their subscriptions, + * update payment methods, and view invoices. + * + * @param {String} accountId - The Stripe connected account ID + * @param {String} customerId - The Stripe customer ID + * @param {String} returnUrl - URL to redirect after portal session + * @returns {Promise} Billing portal session with url property + */ + async createBillingPortalSession (accountId, customerId, returnUrl) { + try { + if (!accountId) { + throw new Error('Account ID is required to create a billing portal session') + } + + if (!customerId) { + throw new Error('Customer ID is required to create a billing portal session') + } + + if (!returnUrl) { + throw new Error('Return URL is required') + } + + const session = await stripe.billingPortal.sessions.create({ + customer: customerId, + return_url: returnUrl + }, { + stripeAccount: accountId + }) + + return session + } catch (error) { + console.error('Error creating billing portal session:', error) + throw new Error(`Failed to create billing portal session: ${error.message}`) + } + }, + + /** + * Retrieves enriched transaction data from Stripe + * + * Fetches subscription or checkout session data to enrich our + * transaction records with payment details. + * + * @param {String} accountId - The Stripe connected account ID + * @param {String} subscriptionId - Optional subscription ID + * @param {String} sessionId - Optional checkout session ID + * @returns {Promise} Enriched data object + */ + async getTransactionDetails (accountId, { subscriptionId, sessionId }) { + try { + if (!accountId) { + return null + } + + const result = { + subscriptionStatus: null, + currentPeriodEnd: null, + amountPaid: null, + currency: null, + customerId: null, + receiptUrl: null + } + + // If we have a subscription ID, get subscription details + if (subscriptionId) { + try { + const subscription = await stripe.subscriptions.retrieve(subscriptionId, { + expand: ['latest_invoice', 'items.data.price'] + }, { + stripeAccount: accountId + }) + + result.subscriptionStatus = subscription.status + result.currentPeriodEnd = subscription.current_period_end + ? new Date(subscription.current_period_end * 1000) + : null + result.customerId = subscription.customer + + // Get amount from the subscription items + if (subscription.items?.data?.[0]?.price) { + result.amountPaid = subscription.items.data[0].price.unit_amount + result.currency = subscription.items.data[0].price.currency + } + + // Get receipt URL from latest invoice + if (subscription.latest_invoice?.hosted_invoice_url) { + result.receiptUrl = subscription.latest_invoice.hosted_invoice_url + } + } catch (subError) { + console.error('Error fetching subscription:', subscriptionId, subError.message) + // Continue - we may still have session data + } + } + + // If we have a session ID and no subscription data, get session details + if (sessionId && !result.customerId) { + try { + const session = await stripe.checkout.sessions.retrieve(sessionId, { + expand: ['payment_intent', 'line_items'] + }, { + stripeAccount: accountId + }) + + result.customerId = session.customer + result.amountPaid = session.amount_total + result.currency = session.currency + + // For one-time payments, get receipt from payment intent + if (session.payment_intent?.charges?.data?.[0]?.receipt_url) { + result.receiptUrl = session.payment_intent.charges.data[0].receipt_url + } + } catch (sessError) { + console.error('Error fetching checkout session:', sessionId, sessError.message) + } + } + + return result + } catch (error) { + console.error('Error getting transaction details:', error) + return null + } + }, + + /** + * Utility function to format price from cents to display string + * + * @param {Number} cents - Price in cents + * @param {String} currency - Currency code + * @returns {String} Formatted price string (e.g. "$20.00") + */ + formatPrice (cents, currency = 'usd') { + const dollars = cents / 100 + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency.toUpperCase() + }).format(dollars) + }, + + /** + * Transfers a donation amount from a connected account to the appropriate destination + * + * When a user adds a donation to Hylo during checkout, the donation is initially + * collected by the connected account. This method transfers it to: + * - Production: Hylo's connected account under the fiscal sponsor (for tax-deductible processing) + * - Non-production: Platform account (for testing) + * + * @param {String} connectedAccountId - The Stripe connected account ID (group's account) + * @param {String} paymentIntentId - The payment intent ID from the checkout session + * @param {Number} donationAmount - The donation amount in cents + * @param {String} currency - The currency code (e.g., 'usd') + * @returns {Promise} The transfer object + */ + async transferDonationToPlatform ({ + connectedAccountId, + paymentIntentId, + donationAmount, + currency = 'usd' + }) { + try { + if (!connectedAccountId) { + throw new Error('Connected account ID is required') + } + + if (!paymentIntentId) { + throw new Error('Payment intent ID is required') + } + + if (!donationAmount || donationAmount <= 0) { + throw new Error('Valid donation amount is required') + } + + // Determine destination account based on environment + const isProduction = process.env.NODE_ENV === 'production' + const destinationAccountId = isProduction ? FISCAL_SPONSOR_ACCOUNT_ID : null + + // In non-production, transfer to platform account (null destination = platform) + // In production, transfer to fiscal sponsor account (Hylo's connected account) + // In production, we require the fiscal sponsor account ID to be set + if (isProduction && !destinationAccountId) { + throw new Error( + 'STRIPE_FISCAL_SPONSOR_ACCOUNT_ID must be set in production environment. ' + + 'Donations must be routed to the fiscal sponsor account for tax-deductible processing.' + ) + } + + // Retrieve the payment intent to get the charge ID + const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId, { + expand: ['charges'] + }, { + stripeAccount: connectedAccountId + }) + + // Get the charge ID from the payment intent + const chargeId = paymentIntent.charges?.data?.[0]?.id + if (!chargeId) { + throw new Error('No charge found for payment intent') + } + + // Build transfer parameters + const transferParams = { + amount: donationAmount, + currency: currency.toLowerCase(), + source_transaction: chargeId, + description: isProduction + ? 'Tax-deductible donation to Hylo (501(c)(3) fiscally sponsored)' + : 'Donation to Hylo platform (test)' + } + + // If in production and fiscal sponsor account is set, transfer to that account + // Otherwise, transfer to platform account (destination: null) + if (isProduction && destinationAccountId) { + transferParams.destination = destinationAccountId + } + + // Create the transfer on the platform account + // The source account is determined by the source_transaction (charge ID) + // For connected accounts with controller settings: + // - If destination is null/omitted: transfers to platform account + // - If destination is set to a connected account ID: transfers to that connected account + // Note: Transfers are created on the platform account, not with stripeAccount header + const transfer = await stripe.transfers.create(transferParams) + + if (process.env.NODE_ENV === 'development') { + console.log(`Transferred donation of ${donationAmount} ${currency} to ${isProduction && destinationAccountId ? 'fiscal sponsor account' : 'platform account'}`) + } + + return transfer + } catch (error) { + console.error('Error transferring donation:', error) + throw new Error(`Failed to transfer donation: ${error.message}`) + } + }, + + /** + * Cancels a subscription on a connected account + * + * Immediately cancels the subscription. The customer will lose access + * at the end of the current billing period unless prorate is specified. + * + * @param {Object} params - Cancellation parameters + * @param {String} params.accountId - The Stripe connected account ID + * @param {String} params.subscriptionId - The subscription ID to cancel + * @param {Boolean} [params.immediately=true] - If true, cancel immediately. If false, cancel at period end. + * @returns {Promise} The cancelled subscription object + */ + async cancelSubscription ({ accountId, subscriptionId, immediately = true }) { + try { + if (!accountId) { + throw new Error('Account ID is required to cancel a subscription') + } + + if (!subscriptionId) { + throw new Error('Subscription ID is required') + } + + let subscription + + if (immediately) { + // Cancel immediately - customer loses access right away + subscription = await stripe.subscriptions.cancel(subscriptionId, {}, { + stripeAccount: accountId + }) + } else { + // Cancel at period end - customer keeps access until current period ends + subscription = await stripe.subscriptions.update(subscriptionId, { + cancel_at_period_end: true + }, { + stripeAccount: accountId + }) + } + + if (process.env.NODE_ENV === 'development') { + console.log(`Subscription ${subscriptionId} cancelled ${immediately ? 'immediately' : 'at period end'}`) + } + + return subscription + } catch (error) { + console.error('Error cancelling subscription:', error) + throw new Error(`Failed to cancel subscription: ${error.message}`) + } + }, + + /** + * Issues a refund for a payment on a connected account + * + * Can refund either by charge ID, payment intent ID, or the latest invoice + * of a subscription. Supports full and partial refunds. + * + * @param {Object} params - Refund parameters + * @param {String} params.accountId - The Stripe connected account ID + * @param {String} [params.chargeId] - The charge ID to refund + * @param {String} [params.paymentIntentId] - The payment intent ID to refund + * @param {String} [params.subscriptionId] - The subscription ID (will refund latest invoice) + * @param {Number} [params.amount] - Amount to refund in cents (omit for full refund) + * @param {String} [params.reason] - Reason for refund: 'duplicate', 'fraudulent', or 'requested_by_customer' + * @returns {Promise} The refund object + */ + async refund ({ accountId, chargeId, paymentIntentId, subscriptionId, amount, reason = 'requested_by_customer' }) { + try { + if (!accountId) { + throw new Error('Account ID is required to issue a refund') + } + + if (!chargeId && !paymentIntentId && !subscriptionId) { + throw new Error('Either chargeId, paymentIntentId, or subscriptionId is required') + } + + let refundParams = {} + + // If we have a subscription ID, find a refundable payment + if (subscriptionId && !chargeId && !paymentIntentId) { + let foundPayment = false + const isDev = process.env.NODE_ENV === 'development' + + if (isDev) { + console.log(`[Refund Debug] Looking for payment for subscription: ${subscriptionId} on account: ${accountId}`) + } + + // Strategy 1: Try listing paid invoices for this subscription + try { + const invoices = await stripe.invoices.list({ + subscription: subscriptionId, + status: 'paid', + limit: 5 + }, { + stripeAccount: accountId + }) + + if (isDev) { + console.log(`[Refund Debug] Strategy 1 - Found ${invoices.data.length} paid invoices`) + } + + // Find the most recent paid invoice with a charge or payment_intent + for (const invoice of invoices.data) { + if (isDev) { + console.log(`[Refund Debug] Invoice ${invoice.id}: amount_due=${invoice.amount_due}, amount_paid=${invoice.amount_paid}, total=${invoice.total}, payment_intent=${invoice.payment_intent}, charge=${invoice.charge}`) + } + if (invoice.payment_intent) { + paymentIntentId = typeof invoice.payment_intent === 'string' + ? invoice.payment_intent + : invoice.payment_intent.id + foundPayment = true + if (isDev) { + console.log(`[Refund Debug] Found payment_intent: ${paymentIntentId}`) + } + break + } else if (invoice.charge) { + chargeId = typeof invoice.charge === 'string' + ? invoice.charge + : invoice.charge.id + foundPayment = true + if (isDev) { + console.log(`[Refund Debug] Found charge: ${chargeId}`) + } + break + } + } + } catch (invoiceListError) { + if (isDev) { + console.log('[Refund Debug] Strategy 1 failed:', invoiceListError.message) + } + } + + // Strategy 2: Retrieve subscription with latest_invoice expanded + // This is more reliable for Checkout-created subscriptions where the + // invoice might not immediately appear in the 'paid' status list + if (!foundPayment) { + if (isDev) { + console.log('[Refund Debug] Strategy 2 - Retrieving subscription with expanded latest_invoice') + } + try { + const subscription = await stripe.subscriptions.retrieve(subscriptionId, { + expand: ['latest_invoice.payment_intent', 'latest_invoice.charge'] + }, { + stripeAccount: accountId + }) + + if (isDev) { + console.log(`[Refund Debug] Subscription status: ${subscription.status}`) + } + const latestInvoice = subscription.latest_invoice + if (isDev) { + console.log(`[Refund Debug] Latest invoice: ${latestInvoice ? latestInvoice.id : 'null'}, status: ${latestInvoice?.status}, amount_due: ${latestInvoice?.amount_due}, amount_paid: ${latestInvoice?.amount_paid}, total: ${latestInvoice?.total}`) + } + + if (latestInvoice) { + if (isDev) { + console.log(`[Refund Debug] Latest invoice payment_intent: ${latestInvoice.payment_intent?.id || latestInvoice.payment_intent || 'null'}`) + console.log(`[Refund Debug] Latest invoice charge: ${latestInvoice.charge?.id || latestInvoice.charge || 'null'}`) + } + + if (latestInvoice.payment_intent) { + paymentIntentId = typeof latestInvoice.payment_intent === 'string' + ? latestInvoice.payment_intent + : latestInvoice.payment_intent.id + foundPayment = true + if (isDev) { + console.log(`[Refund Debug] Found payment_intent from latest_invoice: ${paymentIntentId}`) + } + } else if (latestInvoice.charge) { + chargeId = typeof latestInvoice.charge === 'string' + ? latestInvoice.charge + : latestInvoice.charge.id + foundPayment = true + if (isDev) { + console.log(`[Refund Debug] Found charge from latest_invoice: ${chargeId}`) + } + } + } + } catch (subscriptionError) { + if (isDev) { + console.log('[Refund Debug] Strategy 2 failed:', subscriptionError.message) + } + } + } + + // Strategy 3: List ALL invoices (not just 'paid' status) for this subscription + if (!foundPayment) { + if (isDev) { + console.log('[Refund Debug] Strategy 3 - Listing all invoices for subscription') + } + try { + const allInvoices = await stripe.invoices.list({ + subscription: subscriptionId, + limit: 10 + }, { + stripeAccount: accountId + }) + + if (isDev) { + console.log(`[Refund Debug] Found ${allInvoices.data.length} total invoices`) + } + for (const invoice of allInvoices.data) { + if (isDev) { + console.log(`[Refund Debug] Invoice ${invoice.id}: status=${invoice.status}, amount_due=${invoice.amount_due}, amount_paid=${invoice.amount_paid}, total=${invoice.total}, payment_intent=${invoice.payment_intent}, charge=${invoice.charge}`) + } + if (invoice.payment_intent) { + paymentIntentId = typeof invoice.payment_intent === 'string' + ? invoice.payment_intent + : invoice.payment_intent.id + foundPayment = true + if (isDev) { + console.log(`[Refund Debug] Found payment_intent: ${paymentIntentId}`) + } + break + } else if (invoice.charge) { + chargeId = typeof invoice.charge === 'string' + ? invoice.charge + : invoice.charge.id + foundPayment = true + if (isDev) { + console.log(`[Refund Debug] Found charge: ${chargeId}`) + } + break + } + } + } catch (allInvoicesError) { + if (isDev) { + console.log('[Refund Debug] Strategy 3 failed:', allInvoicesError.message) + } + } + } + + // Strategy 4: Get the subscription's customer and list their charges + if (!foundPayment) { + if (isDev) { + console.log('[Refund Debug] Strategy 4 - Looking up charges via subscription customer') + } + try { + const subscription = await stripe.subscriptions.retrieve(subscriptionId, {}, { + stripeAccount: accountId + }) + const customerId = subscription.customer + if (isDev) { + console.log(`[Refund Debug] Subscription customer: ${customerId}`) + } + + if (customerId) { + const charges = await stripe.charges.list({ + customer: typeof customerId === 'string' ? customerId : customerId.id, + limit: 10 + }, { + stripeAccount: accountId + }) + + if (isDev) { + console.log(`[Refund Debug] Found ${charges.data.length} charges for customer`) + } + for (const charge of charges.data) { + if (isDev) { + console.log(`[Refund Debug] Charge ${charge.id}: amount=${charge.amount}, status=${charge.status}, paid=${charge.paid}, refunded=${charge.refunded}`) + } + if (charge.paid && !charge.refunded) { + chargeId = charge.id + foundPayment = true + if (isDev) { + console.log(`[Refund Debug] Found refundable charge: ${chargeId}`) + } + break + } + } + } + } catch (chargeError) { + if (isDev) { + console.log('[Refund Debug] Strategy 4 failed:', chargeError.message) + } + } + } + + // Strategy 5: List payment intents for the customer + if (!foundPayment) { + if (isDev) { + console.log('[Refund Debug] Strategy 5 - Looking up payment intents via customer') + } + try { + const subscription = await stripe.subscriptions.retrieve(subscriptionId, {}, { + stripeAccount: accountId + }) + const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id + + if (customerId) { + const paymentIntents = await stripe.paymentIntents.list({ + customer: customerId, + limit: 10 + }, { + stripeAccount: accountId + }) + + if (isDev) { + console.log(`[Refund Debug] Found ${paymentIntents.data.length} payment intents for customer`) + } + for (const pi of paymentIntents.data) { + if (isDev) { + console.log(`[Refund Debug] PaymentIntent ${pi.id}: amount=${pi.amount}, status=${pi.status}`) + } + if (pi.status === 'succeeded') { + paymentIntentId = pi.id + foundPayment = true + if (isDev) { + console.log(`[Refund Debug] Found refundable payment intent: ${paymentIntentId}`) + } + break + } + } + } + } catch (piError) { + if (isDev) { + console.log('[Refund Debug] Strategy 5 failed:', piError.message) + } + } + } + + if (!foundPayment) { + if (isDev) { + console.log('[Refund Debug] All strategies failed - no payment found') + } + throw new Error('No refundable payment found for this subscription. Could not find any charges or payment intents.') + } + } + + // Build refund parameters + if (chargeId) { + refundParams.charge = chargeId + } else if (paymentIntentId) { + refundParams.payment_intent = paymentIntentId + } + + if (amount) { + refundParams.amount = amount + } + + if (reason) { + refundParams.reason = reason + } + + // Issue the refund on the connected account + const refund = await stripe.refunds.create(refundParams, { + stripeAccount: accountId + }) + + if (process.env.NODE_ENV === 'development') { + console.log(`Refund issued: ${refund.id} for ${refund.amount} ${refund.currency}`) + } + + return refund + } catch (error) { + console.error('Error issuing refund:', error) + throw new Error(`Failed to issue refund: ${error.message}`) + } + } +} diff --git a/apps/backend/config/customMiddleware.js b/apps/backend/config/customMiddleware.js index d745b30266..993deac256 100644 --- a/apps/backend/config/customMiddleware.js +++ b/apps/backend/config/customMiddleware.js @@ -15,6 +15,9 @@ export default function (app) { // XXX: has to come before bodyParser? app.use('/noo/oauth', oidc.callback()) + // Capture raw body for Stripe webhook before JSON parsing + app.use('/noo/stripe/webhook', bodyParser.raw({ type: 'application/json' })) + app.use(bodyParser.urlencoded({ extended: true })) app.use(bodyParser.json()) diff --git a/apps/backend/config/policies.js b/apps/backend/config/policies.js index b1c0367f49..a0de32ab00 100644 --- a/apps/backend/config/policies.js +++ b/apps/backend/config/policies.js @@ -89,5 +89,12 @@ module.exports.policies = { PaymentController: { registerStripe: ['sessionAuth'] + }, + + StripeController: { + webhook: true, + checkoutSuccess: true, + checkoutCancel: true, + health: true } } diff --git a/apps/backend/config/routes.js b/apps/backend/config/routes.js index f36f44120f..bc8124f86b 100644 --- a/apps/backend/config/routes.js +++ b/apps/backend/config/routes.js @@ -62,6 +62,12 @@ module.exports.routes = { 'GET /noo/payment/registerStripe': 'PaymentController.registerStripe', 'POST /noo/payment/registerStripe': 'PaymentController.registerStripe', + // Stripe Connect routes + 'GET /noo/stripe/health': 'StripeController.health', + 'GET /noo/stripe/checkout/success': 'StripeController.checkoutSuccess', + 'GET /noo/stripe/checkout/cancel': 'StripeController.checkoutCancel', + 'POST /noo/stripe/webhook': 'StripeController.webhook', + // websockets routes 'POST /noo/user/subscribe': 'UserController.subscribeToUpdates', 'POST /noo/user/unsubscribe': 'UserController.unsubscribeFromUpdates', diff --git a/apps/backend/cron.js b/apps/backend/cron.js index f47a9fb413..d6ff8d5b5e 100644 --- a/apps/backend/cron.js +++ b/apps/backend/cron.js @@ -32,6 +32,12 @@ const daily = now => { sails.log.debug('Checking funding round reminders') tasks.push(FundingRound.sendReminderNotifications().then(count => sails.log.debug(`Sent ${count} funding round reminder notifications`))) + sails.log.debug('Sending subscription renewal reminders') + tasks.push(ContentAccess.sendRenewalReminders().then(count => sails.log.debug(`Sent ${count} subscription renewal reminder emails`))) + + sails.log.debug('Sending expired access notifications') + tasks.push(ContentAccess.sendExpiredAccessNotifications().then(count => sails.log.debug(`Sent ${count} expired access notification emails`))) + sails.log.debug('Cleaning up expired OIDC payloads') tasks.push(OIDCAdapter.cleanupExpired().then(count => { sails.log.debug(`Removed ${count} expired OIDC payloads`) diff --git a/apps/backend/lib/i18n/en.js b/apps/backend/lib/i18n/en.js index 2a9191a5e3..41cfb9430e 100644 --- a/apps/backend/lib/i18n/en.js +++ b/apps/backend/lib/i18n/en.js @@ -90,5 +90,8 @@ exports.en = { } return `${reminderMessages[reminderType] || 'Deadline approaching'}` }, - theTeamAtHylo: 'The Team at Hylo' + theTeamAtHylo: 'The Team at Hylo', + donationTaxReceiptInfo: () => 'A tax receipt will be issued by our fiscal sponsor for your records.', + donationImpactMessage: () => 'Your donation helps support the Hylo platform and our mission to enable better coordination and collaboration in communities worldwide.', + donationRecurringImpactMessage: () => 'Your recurring donation helps support the Hylo platform and our mission to enable better coordination and collaboration in communities worldwide.' } diff --git a/apps/backend/lib/i18n/es.js b/apps/backend/lib/i18n/es.js index 65f0c40f42..b2417f4875 100644 --- a/apps/backend/lib/i18n/es.js +++ b/apps/backend/lib/i18n/es.js @@ -92,5 +92,8 @@ exports.es = { } return `${fundingRound.get('title')}: ${reminderMessages[reminderType] || 'Fecha límite próxima'}` }, - theTeamAtHylo: 'El equipo de Hylo' + theTeamAtHylo: 'El equipo de Hylo', + donationTaxReceiptInfo: () => 'Se emitirá un recibo fiscal por nuestro patrocinador fiscal para sus registros.', + donationImpactMessage: () => 'Tu donación ayuda a apoyar la plataforma Hylo y nuestra misión de permitir una mejor coordinación y colaboración en comunidades de todo el mundo.', + donationRecurringImpactMessage: () => 'Tu donación recurrente ayuda a apoyar la plataforma Hylo y nuestra misión de permitir una mejor coordinación y colaboración en comunidades de todo el mundo.' } diff --git a/apps/backend/lib/scopes.js b/apps/backend/lib/scopes.js new file mode 100644 index 0000000000..214d4f0758 --- /dev/null +++ b/apps/backend/lib/scopes.js @@ -0,0 +1,224 @@ +/** + * Scope Helper Functions + * + * Manages scope strings for the user access system. + * + * Scope formats: + * - group: - e.g., "group:123" + * - track: - e.g., "track:456" + * - group_role:: - e.g., "group_role:123:789" + * - common_role:: - e.g., "common_role:123:1" + */ + +const SCOPE_TYPES = { + GROUP: 'group', + TRACK: 'track', + GROUP_ROLE: 'group_role', + COMMON_ROLE: 'common_role' +} + +/** + * Creates a scope string from type and entity ID + * + * @param {string} type - One of: 'group', 'track', 'group_role' + * @param {string|number} entityId - The ID of the entity + * @returns {string} The formatted scope string + * @throws {Error} If type is invalid or entityId is missing + */ +function createScope (type, entityId) { + if (!Object.values(SCOPE_TYPES).includes(type)) { + throw new Error(`Invalid scope type: ${type}. Must be one of: ${Object.values(SCOPE_TYPES).join(', ')}`) + } + + if (!entityId) { + throw new Error('Entity ID is required to create a scope') + } + + return `${type}:${entityId}` +} + +/** + * Parses a scope string into its component parts + * + * @param {string} scopeString - The scope string to parse + * @returns {Object|null} Object with {type, entityId, groupId} or null if invalid + */ +function parseScope (scopeString) { + if (!scopeString || typeof scopeString !== 'string') { + return null + } + + const parts = scopeString.split(':') + + // Simple scopes (group, track): type:entityId + if (parts.length === 2) { + const [type, entityId] = parts + + if (!Object.values(SCOPE_TYPES).includes(type)) { + return null + } + + if (!entityId) { + return null + } + + // Role scopes should have 3 parts, so if it's a role type with only 2 parts, it's invalid + if (type === SCOPE_TYPES.GROUP_ROLE || type === SCOPE_TYPES.COMMON_ROLE) { + return null + } + + return { + type, + entityId + } + } + + // Role scopes: type:groupId:roleId + if (parts.length === 3) { + const [type, groupId, roleId] = parts + + if (type !== SCOPE_TYPES.GROUP_ROLE && type !== SCOPE_TYPES.COMMON_ROLE) { + return null + } + + if (!groupId || !roleId) { + return null + } + + return { + type, + groupId, + entityId: roleId + } + } + + return null +} + +/** + * Validates a scope string + * + * @param {string} scopeString - The scope string to validate + * @returns {boolean} True if valid, false otherwise + */ +function isValidScope (scopeString) { + return parseScope(scopeString) !== null +} + +/** + * Creates a group scope + * + * @param {string|number} groupId - The group ID + * @returns {string} The group scope string + */ +function createGroupScope (groupId) { + return createScope(SCOPE_TYPES.GROUP, groupId) +} + +/** + * Creates a track scope + * + * @param {string|number} trackId - The track ID + * @returns {string} The track scope string + */ +function createTrackScope (trackId) { + return createScope(SCOPE_TYPES.TRACK, trackId) +} + +/** + * Creates a group role scope + * + * @param {string|number} groupRoleId - The group role ID + * @param {string|number} groupId - The group ID (required to avoid collisions) + * @returns {string} The group role scope string + */ +function createGroupRoleScope (groupRoleId, groupId) { + if (!groupId) { + throw new Error('groupId is required for group role scopes') + } + return `${SCOPE_TYPES.GROUP_ROLE}:${groupId}:${groupRoleId}` +} + +/** + * Creates a common role scope + * + * @param {string|number} commonRoleId - The common role ID + * @param {string|number} groupId - The group ID (required to avoid collisions) + * @returns {string} The common role scope string + */ +function createCommonRoleScope (commonRoleId, groupId) { + if (!groupId) { + throw new Error('groupId is required for common role scopes') + } + return `${SCOPE_TYPES.COMMON_ROLE}:${groupId}:${commonRoleId}` +} + +/** + * Checks if a scope is of a specific type + * + * @param {string} scopeString - The scope string to check + * @param {string} type - The type to check against + * @returns {boolean} True if the scope is of the specified type + */ +function isScopeOfType (scopeString, type) { + const parsed = parseScope(scopeString) + return parsed ? parsed.type === type : false +} + +/** + * Extracts the entity ID from a scope string + * + * @param {string} scopeString - The scope string + * @returns {string|null} The entity ID or null if invalid + */ +function getEntityIdFromScope (scopeString) { + const parsed = parseScope(scopeString) + return parsed ? parsed.entityId : null +} + +/** + * Creates multiple scopes from a content access definition + * + * @param {Object} contentAccess - Content access object with trackIds, groupIds + * @returns {string[]} Array of scope strings + */ +function createScopesFromContentAccess (contentAccess) { + if (!contentAccess || typeof contentAccess !== 'object') { + return [] + } + + const scopes = [] + + // Add track scopes + if (Array.isArray(contentAccess.trackIds)) { + contentAccess.trackIds.forEach(trackId => { + scopes.push(createTrackScope(trackId)) + }) + } + + // Add group scopes + if (Array.isArray(contentAccess.groupIds)) { + contentAccess.groupIds.forEach(groupId => { + scopes.push(createGroupScope(groupId)) + }) + } + + // Note: Role scopes require groupId, so they cannot be created from accessGrants alone. Admins can already give roles to people, without the use of access grants + // For paid content, roles must be created from content_access records which have both groupRoleId and groupId + + return scopes +} + +module.exports = { + SCOPE_TYPES, + createScope, + parseScope, + isValidScope, + createGroupScope, + createTrackScope, + createGroupRoleScope, + createCommonRoleScope, + isScopeOfType, + getEntityIdFromScope, + createScopesFromContentAccess +} diff --git a/apps/backend/migrations/20251020160838_paid-content-stripe.js b/apps/backend/migrations/20251020160838_paid-content-stripe.js new file mode 100644 index 0000000000..602e75c8c2 --- /dev/null +++ b/apps/backend/migrations/20251020160838_paid-content-stripe.js @@ -0,0 +1,248 @@ +exports.up = async function (knex) { + // First, drop the existing foreign key from users to stripe_accounts + await knex.schema.table('users', function (table) { + table.dropForeign(['stripe_account_id']) + table.dropColumn('stripe_account_id') + }) + + // Add Stripe columns to groups table + await knex.schema.table('groups', function (table) { + table.bigInteger('stripe_account_id').unsigned().references('id').inTable('stripe_accounts') + table.boolean('stripe_charges_enabled').defaultTo(false) + table.boolean('stripe_payouts_enabled').defaultTo(false) + table.boolean('stripe_details_submitted').defaultTo(false) + }) + + // Create stripe_products table to track products associated with groups + await knex.schema.createTable('stripe_products', function (table) { + table.bigIncrements('id').primary() + table.bigInteger('group_id').unsigned().notNullable().references('id').inTable('groups').onDelete('CASCADE') + table.string('stripe_product_id', 255).notNullable() + table.string('stripe_price_id', 255).notNullable() + table.string('name', 255).notNullable() + table.text('description') + table.integer('price_in_cents').notNullable() + table.string('currency', 3).notNullable().defaultTo('usd') + table.integer('track_id').unsigned().references('id').inTable('tracks') + table.jsonb('content_access').defaultTo('{}').comment('Defines what access this product grants - groups, tracks, roles') + table.string('renewal_policy', 20).defaultTo('manual').comment('Renewal policy: automatic or manual') + table.string('duration', 20).comment('Duration: month, season, annual, lifetime, or null for no expiration') + table.string('publish_status', 20).defaultTo('unpublished').comment('Publish status: unpublished, unlisted, published, archived') + table.timestamps(true, true) + + table.index(['group_id']) + table.index(['stripe_product_id']) + }) + + // Create content_access table to track all content access grants (paid and free) + // This supports both Stripe purchases and admin-granted free access + await knex.schema.createTable('content_access', function (table) { + table.bigIncrements('id').primary() + table.bigInteger('user_id').unsigned().notNullable().references('id').inTable('users') + table.bigInteger('granted_by_group_id').unsigned().notNullable().references('id').inTable('groups') + table.bigInteger('group_id').unsigned().references('id').inTable('groups') + table.bigInteger('product_id').unsigned().references('id').inTable('stripe_products') + table.integer('track_id').unsigned().references('id').inTable('tracks') + table.integer('role_id').unsigned().references('id').inTable('groups_roles') + + // Access type: 'stripe_purchase', 'admin_grant', 'free' + table.string('access_type', 50).notNullable() + + // Stripe-specific fields (nullable for non-Stripe grants) + table.string('stripe_session_id', 255) + table.string('stripe_payment_intent_id', 255) + + // Status: 'active', 'expired', 'revoked' + table.string('status', 50).notNullable().defaultTo('active') + + // Who granted access (for admin grants) + table.bigInteger('granted_by_id').unsigned().references('id').inTable('users') + + // Optional expiration + table.timestamp('expires_at') + + // Flexible metadata for additional info + table.jsonb('metadata').defaultTo('{}') + + table.timestamps(true, true) + + table.index(['user_id', 'status']) + table.index(['granted_by_group_id']) + table.index(['group_id']) + table.index(['product_id']) + table.index(['track_id']) + table.index(['role_id']) + table.index(['stripe_session_id']) + table.index(['access_type']) + table.index(['status']) + }) + + // Add expires_at columns to related tables to mirror content_access expiration + await knex.schema.table('group_memberships', function (table) { + table.timestamp('expires_at').comment('Mirrored from content_access table via trigger') + }) + + await knex.schema.table('tracks_users', function (table) { + table.boolean('access_granted').comment('Whether user has access to track (set via trigger)') + }) + + await knex.schema.table('group_memberships_group_roles', function (table) { + table.timestamp('expires_at').comment('Mirrored from content_access table via trigger') + }) + + // Add access_controlled flag to tracks table + // When true, this track requires purchased access (check content_access table) + // When false (default), track is freely accessible to all group members + await knex.schema.table('tracks', function (table) { + table.boolean('access_controlled').defaultTo(false).comment('Whether this track requires purchased access') + }) + + // Add paywall flag to groups table + // When true, this group requires purchased membership (check content_access table) + // When false (default), group is freely joinable per existing access control rules + await knex.schema.table('groups', function (table) { + table.boolean('paywall').defaultTo(false).comment('Whether this group requires purchased membership') + }) + + // Create trigger function to sync content_access expiration to related tables + // This function automatically updates the related tables whenever content_access is modified + // + // IMPORTANT: track_id, role_id, and granted_by_group_id are mutually exclusive + // Only ONE of these will be set for any given content_access record + // + // IMPORTANT: Multiple content_access records can reference the same stripe product + // (e.g., a bundle purchase, recurring subscriptions, or multiple purchases over time) + // + // IMPORTANT: Uses the MOST RECENT expires_at from ALL active content_access records + // This prevents old/expired records from overwriting newer access grants + await knex.raw(` + CREATE OR REPLACE FUNCTION sync_content_access_expires_at() + RETURNS TRIGGER AS $$ + DECLARE + latest_expires_at TIMESTAMP WITH TIME ZONE; + BEGIN + -- Track-level access: sync to tracks_users + IF NEW.track_id IS NOT NULL THEN + UPDATE tracks_users + SET access_granted = true, updated_at = NOW() + WHERE user_id = NEW.user_id AND track_id = NEW.track_id; + END IF; + + -- Role-level access: sync to group_memberships_group_roles and group_memberships + IF NEW.role_id IS NOT NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at FROM content_access WHERE user_id = NEW.user_id AND role_id = NEW.role_id AND granted_by_group_id = NEW.granted_by_group_id AND status = 'active'; + UPDATE group_memberships_group_roles SET expires_at = latest_expires_at, updated_at = NOW() WHERE user_id = NEW.user_id AND group_id = NEW.granted_by_group_id AND group_role_id = NEW.role_id; + UPDATE group_memberships SET expires_at = latest_expires_at, updated_at = NOW() WHERE user_id = NEW.user_id AND group_id = NEW.granted_by_group_id; + END IF; + + -- Group-level access: sync to group_memberships + IF NEW.track_id IS NULL AND NEW.role_id IS NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at FROM content_access WHERE user_id = NEW.user_id AND granted_by_group_id = NEW.granted_by_group_id AND track_id IS NULL AND role_id IS NULL AND status = 'active'; + UPDATE group_memberships SET expires_at = latest_expires_at, updated_at = NOW() WHERE user_id = NEW.user_id AND group_id = NEW.granted_by_group_id; + END IF; + + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) + + // Create trigger on content_access for INSERT and UPDATE + await knex.raw(` + CREATE TRIGGER content_access_expires_at_sync + AFTER INSERT OR UPDATE OF expires_at ON content_access + FOR EACH ROW + WHEN (NEW.status = 'active') + EXECUTE FUNCTION sync_content_access_expires_at(); + `) + + // Create trigger function to clear expires_at when access is revoked + // Uses the most recent expires_at from remaining active records, or clears if none + await knex.raw(` + CREATE OR REPLACE FUNCTION clear_content_access_expires_at() + RETURNS TRIGGER AS $$ + DECLARE + latest_expires_at TIMESTAMP WITH TIME ZONE; + BEGIN + -- Track-level access: clear from tracks_users + IF NEW.track_id IS NOT NULL THEN + UPDATE tracks_users + SET access_granted = false, updated_at = NOW() + WHERE user_id = NEW.user_id AND track_id = NEW.track_id; + END IF; + + -- Role-level access: clear from group_memberships_group_roles and group_memberships + IF NEW.role_id IS NOT NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at FROM content_access WHERE user_id = NEW.user_id AND role_id = NEW.role_id AND granted_by_group_id = NEW.granted_by_group_id AND status = 'active' AND id != NEW.id; + UPDATE group_memberships_group_roles SET expires_at = latest_expires_at, updated_at = NOW() WHERE user_id = NEW.user_id AND group_id = NEW.granted_by_group_id AND group_role_id = NEW.role_id; + UPDATE group_memberships SET expires_at = latest_expires_at, updated_at = NOW() WHERE user_id = NEW.user_id AND group_id = NEW.granted_by_group_id; + END IF; + + -- Group-level access: clear from group_memberships + IF NEW.track_id IS NULL AND NEW.role_id IS NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at FROM content_access WHERE user_id = NEW.user_id AND granted_by_group_id = NEW.granted_by_group_id AND track_id IS NULL AND role_id IS NULL AND status = 'active' AND id != NEW.id; + UPDATE group_memberships SET expires_at = latest_expires_at, updated_at = NOW() WHERE user_id = NEW.user_id AND group_id = NEW.granted_by_group_id; + END IF; + + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) + + // Create trigger to clear expires_at when status changes to revoked/expired + await knex.raw(` + CREATE TRIGGER content_access_expires_at_clear + AFTER UPDATE OF status ON content_access + FOR EACH ROW + WHEN (NEW.status IN ('revoked', 'expired')) + EXECUTE FUNCTION clear_content_access_expires_at(); + `) +} + +exports.down = async function (knex) { + // Drop triggers first + await knex.raw('DROP TRIGGER IF EXISTS content_access_expires_at_sync ON content_access') + await knex.raw('DROP TRIGGER IF EXISTS content_access_expires_at_clear ON content_access') + await knex.raw('DROP FUNCTION IF EXISTS sync_content_access_expires_at()') + await knex.raw('DROP FUNCTION IF EXISTS clear_content_access_expires_at()') + + // Remove expires_at columns from related tables + await knex.schema.table('group_memberships', function (table) { + table.dropColumn('expires_at') + }) + + await knex.schema.table('tracks_users', function (table) { + table.dropColumn('access_granted') + }) + + await knex.schema.table('group_memberships_group_roles', function (table) { + table.dropColumn('expires_at') + }) + + // Remove access_controlled flag from tracks table + await knex.schema.table('tracks', function (table) { + table.dropColumn('access_controlled') + }) + + // Remove paywall flag from groups table + await knex.schema.table('groups', function (table) { + table.dropColumn('paywall') + }) + + // Drop the new tables + await knex.schema.dropTableIfExists('content_access') + await knex.schema.dropTableIfExists('stripe_products') + + // Remove Stripe columns from groups table + await knex.schema.table('groups', function (table) { + table.dropForeign(['stripe_account_id']) + table.dropColumn('stripe_account_id') + table.dropColumn('stripe_charges_enabled') + table.dropColumn('stripe_payouts_enabled') + table.dropColumn('stripe_details_submitted') + }) + + // Restore the stripe_account_id column to users table + await knex.schema.table('users', function (table) { + table.bigInteger('stripe_account_id').unsigned().references('id').inTable('stripe_accounts') + }) +} diff --git a/apps/backend/migrations/20251110143609_extend-content-access-to-scopes.js b/apps/backend/migrations/20251110143609_extend-content-access-to-scopes.js new file mode 100644 index 0000000000..5b1cbc00ce --- /dev/null +++ b/apps/backend/migrations/20251110143609_extend-content-access-to-scopes.js @@ -0,0 +1,266 @@ +exports.up = async function (knex) { + // Create user_scopes table to materialize user entitlements + await knex.schema.createTable('user_scopes', table => { + table.bigInteger('user_id').references('id').inTable('users').notNullable() + table.string('scope').notNullable() + table.timestamp('expires_at').nullable().comment('Earliest ends_at among sources, null means never expires') + table.string('source_kind').notNullable().comment('Type of source: grant or role') + table.bigInteger('source_id').notNullable().comment('ID of the content_access grant or group_memberships_group_roles record') + table.timestamp('created_at') + table.timestamp('updated_at') + + // Composite primary key on (user_id, scope) + table.primary(['user_id', 'scope']) + }) + + // Create index for fast lookups by user and scope + await knex.raw('CREATE INDEX user_scopes_user_id_scope_index ON user_scopes (user_id, scope)') + + // Create index for expires_at to find expiring scopes + await knex.raw('CREATE INDEX user_scopes_expires_at_index ON user_scopes (expires_at) WHERE expires_at IS NOT NULL') + + // Create index for source lookups + await knex.raw('CREATE INDEX user_scopes_source_index ON user_scopes (source_kind, source_id)') + + // Add scopes column to groups_roles table + await knex.schema.table('groups_roles', table => { + table.jsonb('scopes').nullable().comment('Array of scope strings that this role grants') + }) + + // Rename content_access column to access_grants on stripe_products for clarity + // (content_access is the table name, access_grants is what the offering will grant) + await knex.schema.table('stripe_products', table => { + table.renameColumn('content_access', 'access_grants') + }) + + // Remove old expires_at columns that were mirrored from content_access + await knex.schema.table('group_memberships', table => { + table.dropColumn('expires_at') + }) + + await knex.schema.table('tracks_users', table => { + table.dropColumn('access_granted') + }) + + await knex.schema.table('group_memberships_group_roles', table => { + table.dropColumn('expires_at') + }) + + // Remove old database triggers that updated expires_at + await knex.raw('DROP TRIGGER IF EXISTS content_access_expires_at_sync ON content_access') + await knex.raw('DROP TRIGGER IF EXISTS content_access_expires_at_clear ON content_access') + await knex.raw('DROP FUNCTION IF EXISTS sync_content_access_expires_at()') + await knex.raw('DROP FUNCTION IF EXISTS clear_content_access_expires_at()') + + // Create new database function to compute user_scopes from content_access + await knex.raw(` + CREATE OR REPLACE FUNCTION compute_user_scopes_from_content_access() + RETURNS TRIGGER AS $$ + DECLARE + scope_string TEXT; + BEGIN + -- Only process active content_access records + IF NEW.status = 'active' THEN + -- Determine the scope based on what the content_access grants + + -- Track access: scope format is 'track:' + IF NEW.track_id IS NOT NULL THEN + scope_string := 'track:' || NEW.track_id; + + INSERT INTO user_scopes (user_id, scope, expires_at, source_kind, source_id, created_at, updated_at) + VALUES (NEW.user_id, scope_string, NEW.expires_at, 'grant', NEW.id, NOW(), NOW()) + ON CONFLICT (user_id, scope) + DO UPDATE SET + expires_at = CASE + WHEN user_scopes.expires_at IS NULL OR NEW.expires_at IS NULL THEN NULL + WHEN NEW.expires_at > user_scopes.expires_at THEN NEW.expires_at + ELSE user_scopes.expires_at + END, + updated_at = NOW(); + END IF; + + -- Role access: scope format is 'group_role:' + IF NEW.role_id IS NOT NULL THEN + scope_string := 'group_role:' || NEW.role_id; + + INSERT INTO user_scopes (user_id, scope, expires_at, source_kind, source_id, created_at, updated_at) + VALUES (NEW.user_id, scope_string, NEW.expires_at, 'grant', NEW.id, NOW(), NOW()) + ON CONFLICT (user_id, scope) + DO UPDATE SET + expires_at = CASE + WHEN user_scopes.expires_at IS NULL OR NEW.expires_at IS NULL THEN NULL + WHEN NEW.expires_at > user_scopes.expires_at THEN NEW.expires_at + ELSE user_scopes.expires_at + END, + updated_at = NOW(); + END IF; + + -- Group access: scope format is 'group:' + IF NEW.track_id IS NULL AND NEW.role_id IS NULL AND NEW.granted_by_group_id IS NOT NULL THEN + scope_string := 'group:' || NEW.granted_by_group_id; + + INSERT INTO user_scopes (user_id, scope, expires_at, source_kind, source_id, created_at, updated_at) + VALUES (NEW.user_id, scope_string, NEW.expires_at, 'grant', NEW.id, NOW(), NOW()) + ON CONFLICT (user_id, scope) + DO UPDATE SET + expires_at = CASE + WHEN user_scopes.expires_at IS NULL OR NEW.expires_at IS NULL THEN NULL + WHEN NEW.expires_at > user_scopes.expires_at THEN NEW.expires_at + ELSE user_scopes.expires_at + END, + updated_at = NOW(); + END IF; + ELSE + -- If status is not active (revoked/expired), remove the scope + IF NEW.track_id IS NOT NULL THEN + scope_string := 'track:' || NEW.track_id; + DELETE FROM user_scopes + WHERE user_id = NEW.user_id + AND scope = scope_string + AND source_kind = 'grant' + AND source_id = NEW.id; + END IF; + + IF NEW.role_id IS NOT NULL THEN + scope_string := 'group_role:' || NEW.role_id; + DELETE FROM user_scopes + WHERE user_id = NEW.user_id + AND scope = scope_string + AND source_kind = 'grant' + AND source_id = NEW.id; + END IF; + + IF NEW.track_id IS NULL AND NEW.role_id IS NULL AND NEW.granted_by_group_id IS NOT NULL THEN + scope_string := 'group:' || NEW.granted_by_group_id; + DELETE FROM user_scopes + WHERE user_id = NEW.user_id + AND scope = scope_string + AND source_kind = 'grant' + AND source_id = NEW.id; + END IF; + END IF; + + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) + + // Create new database function to compute user_scopes from role assignments + await knex.raw(` + CREATE OR REPLACE FUNCTION compute_user_scopes_from_role() + RETURNS TRIGGER AS $$ + DECLARE + role_scopes JSONB; + scope_string TEXT; + BEGIN + -- Only process active role assignments + IF NEW.active = true THEN + -- Fetch the scopes array from the groups_roles table + SELECT scopes INTO role_scopes + FROM groups_roles + WHERE id = NEW.group_role_id; + + -- If the role has scopes defined, insert them into user_scopes + IF role_scopes IS NOT NULL THEN + -- Iterate over each scope in the JSONB array + FOR scope_string IN SELECT jsonb_array_elements_text(role_scopes) + LOOP + INSERT INTO user_scopes (user_id, scope, expires_at, source_kind, source_id, created_at, updated_at) + VALUES (NEW.user_id, scope_string, NULL, 'role', NEW.id, NOW(), NOW()) + ON CONFLICT (user_id, scope) + DO UPDATE SET + updated_at = NOW(); + END LOOP; + END IF; + ELSE + -- If role assignment is not active, remove the scopes + -- We need to get the scopes from the role again to know what to delete + SELECT scopes INTO role_scopes + FROM groups_roles + WHERE id = NEW.group_role_id; + + IF role_scopes IS NOT NULL THEN + FOR scope_string IN SELECT jsonb_array_elements_text(role_scopes) + LOOP + DELETE FROM user_scopes + WHERE user_id = NEW.user_id + AND scope = scope_string + AND source_kind = 'role' + AND source_id = NEW.id; + END LOOP; + END IF; + END IF; + + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) + + // Create triggers on content_access table to update user_scopes + await knex.raw(` + CREATE TRIGGER content_access_user_scopes_sync + AFTER INSERT OR UPDATE ON content_access + FOR EACH ROW + EXECUTE FUNCTION compute_user_scopes_from_content_access(); + `) + + await knex.raw(` + CREATE TRIGGER content_access_user_scopes_delete + AFTER DELETE ON content_access + FOR EACH ROW + EXECUTE FUNCTION compute_user_scopes_from_content_access(); + `) + + // Create triggers on group_memberships_group_roles table to update user_scopes + await knex.raw(` + CREATE TRIGGER group_role_assignment_user_scopes_sync + AFTER INSERT OR UPDATE ON group_memberships_group_roles + FOR EACH ROW + EXECUTE FUNCTION compute_user_scopes_from_role(); + `) + + await knex.raw(` + CREATE TRIGGER group_role_assignment_user_scopes_delete + AFTER DELETE ON group_memberships_group_roles + FOR EACH ROW + EXECUTE FUNCTION compute_user_scopes_from_role(); + `) +} + +exports.down = async function (knex) { + // Drop new triggers + await knex.raw('DROP TRIGGER IF EXISTS group_role_assignment_user_scopes_delete ON group_memberships_group_roles') + await knex.raw('DROP TRIGGER IF EXISTS group_role_assignment_user_scopes_sync ON group_memberships_group_roles') + await knex.raw('DROP TRIGGER IF EXISTS content_access_user_scopes_delete ON content_access') + await knex.raw('DROP TRIGGER IF EXISTS content_access_user_scopes_sync ON content_access') + + // Drop new functions + await knex.raw('DROP FUNCTION IF EXISTS compute_user_scopes_from_role()') + await knex.raw('DROP FUNCTION IF EXISTS compute_user_scopes_from_content_access()') + + // Restore old column name + await knex.schema.table('stripe_products', table => { + table.renameColumn('access_grants', 'content_access') + }) + + // Restore old expires_at columns + await knex.schema.table('group_memberships_group_roles', table => { + table.timestamp('expires_at').comment('Mirrored from content_access table via trigger') + }) + + await knex.schema.table('tracks_users', table => { + table.boolean('access_granted').comment('Whether user has access to track (set via trigger)') + }) + + await knex.schema.table('group_memberships', table => { + table.timestamp('expires_at').comment('Mirrored from content_access table via trigger') + }) + + // Remove scopes column from groups_roles + await knex.schema.table('groups_roles', table => { + table.dropColumn('scopes') + }) + + // Drop user_scopes table + await knex.schema.dropTableIfExists('user_scopes') +} diff --git a/apps/backend/migrations/20251110143610_backfill-user-scopes.js b/apps/backend/migrations/20251110143610_backfill-user-scopes.js new file mode 100644 index 0000000000..a0e3257bba --- /dev/null +++ b/apps/backend/migrations/20251110143610_backfill-user-scopes.js @@ -0,0 +1,50 @@ +/** + * Migration: Backfill user_scopes table + * + * This migration populates the user_scopes table with data from existing content_access records. + * Since we've created database triggers that automatically maintain user_scopes when content_access + * records are inserted/updated/deleted, we just need to trigger those functions for existing data. + * + * We do this by touching (updating) all active content_access records, which will fire the + * compute_user_scopes_from_content_access() trigger function. + */ + +exports.up = async function (knex) { + console.log('Starting backfill of user_scopes from existing content_access records...') + + // Get count of active content_access records + const [{ count }] = await knex('content_access') + .where('status', 'active') + .count('* as count') + + console.log(`Found ${count} active content_access records to process`) + + if (parseInt(count, 10) === 0) { + console.log('No records to backfill, migration complete') + return + } + + // Update all active content_access records to trigger the database function + // We use updated_at to track that they've been touched + await knex.raw(` + UPDATE content_access + SET updated_at = NOW() + WHERE status = 'active'; + `) + + // Verify that user_scopes were created + const [{ scopeCount }] = await knex('user_scopes') + .count('* as scopeCount') + + console.log(`Backfill complete! Created ${scopeCount} user_scope records`) +} + +exports.down = async function (knex) { + console.log('Rolling back user_scopes backfill...') + + // Delete all user_scopes records + // Note: This is safe because the triggers will recreate them as needed + await knex('user_scopes').del() + + console.log('User_scopes table cleared') +} diff --git a/apps/backend/migrations/20251117111349_add_stripe_subscription_id_to_content_access.js b/apps/backend/migrations/20251117111349_add_stripe_subscription_id_to_content_access.js new file mode 100644 index 0000000000..1db4c82b61 --- /dev/null +++ b/apps/backend/migrations/20251117111349_add_stripe_subscription_id_to_content_access.js @@ -0,0 +1,36 @@ +exports.up = async function (knex) { + // Add stripe_subscription_id column to content_access table + await knex.schema.table('content_access', table => { + table.string('stripe_subscription_id', 255).nullable() + .comment('Stripe subscription ID for recurring access (null for one-time purchases)') + }) + + // Add index for efficient queries by subscription ID + await knex.raw(` + CREATE INDEX content_access_stripe_subscription_id_index + ON content_access (stripe_subscription_id) + WHERE stripe_subscription_id IS NOT NULL + `) + + // Remove stripe_payment_intent_id column (no longer needed with Checkout Session approach) + await knex.schema.table('content_access', table => { + table.dropColumn('stripe_payment_intent_id') + }) +} + +exports.down = async function (knex) { + // Re-add stripe_payment_intent_id column + await knex.schema.table('content_access', table => { + table.string('stripe_payment_intent_id', 255).nullable() + }) + + // Remove index + await knex.schema.table('content_access', table => { + table.dropIndex('stripe_subscription_id', 'content_access_stripe_subscription_id_index') + }) + + // Remove column + await knex.schema.table('content_access', table => { + table.dropColumn('stripe_subscription_id') + }) +} diff --git a/apps/backend/migrations/20260102150000_add-role-id-to-group-invites.js b/apps/backend/migrations/20260102150000_add-role-id-to-group-invites.js new file mode 100644 index 0000000000..78642b9b4f --- /dev/null +++ b/apps/backend/migrations/20260102150000_add-role-id-to-group-invites.js @@ -0,0 +1,23 @@ +/** + * Add role columns to group_invites table + * + * This allows email invitations to specify roles that will be + * assigned to the user when they join the group via the invitation. + * + * - common_role_id: References common_roles table (Coordinator=1, Moderator=2, Host=3) + * - group_role_id: References groups_roles table (group-specific custom roles) + */ + +exports.up = async function (knex) { + await knex.schema.alterTable('group_invites', table => { + table.bigInteger('common_role_id').references('id').inTable('common_roles') + table.bigInteger('group_role_id').references('id').inTable('groups_roles') + }) +} + +exports.down = async function (knex) { + await knex.schema.alterTable('group_invites', table => { + table.dropColumn('common_role_id') + table.dropColumn('group_role_id') + }) +} diff --git a/apps/backend/migrations/20260116151442_split_role_id_in_content_access.js b/apps/backend/migrations/20260116151442_split_role_id_in_content_access.js new file mode 100644 index 0000000000..1f141c5b79 --- /dev/null +++ b/apps/backend/migrations/20260116151442_split_role_id_in_content_access.js @@ -0,0 +1,477 @@ +exports.up = async function (knex) { + // Add new columns for common_role_id and group_role_id + await knex.schema.table('content_access', table => { + table.integer('common_role_id').unsigned().references('id').inTable('common_roles').nullable() + table.integer('group_role_id').unsigned().references('id').inTable('groups_roles').nullable() + }) + + // Migrate existing role_id data to group_role_id + // Since role_id currently references groups_roles, all existing data should go to group_role_id + await knex.raw(` + UPDATE content_access + SET group_role_id = role_id + WHERE role_id IS NOT NULL + `) + + // Add indexes for the new columns + await knex.schema.table('content_access', table => { + table.index(['common_role_id']) + table.index(['group_role_id']) + }) + + // Drop the old role_id column and its index + await knex.schema.table('content_access', table => { + table.dropIndex(['role_id']) + table.dropForeign(['role_id']) + table.dropColumn('role_id') + }) + + // Update the existing database trigger function to handle both common_role_id and group_role_id + // This function was created in migration 20251110143609_extend-content-access-to-scopes.js + // and is called by triggers on content_access table, so we must update it to reference + // the new columns instead of the old role_id column + await knex.raw(` + CREATE OR REPLACE FUNCTION compute_user_scopes_from_content_access() + RETURNS TRIGGER AS $$ + DECLARE + scope_string TEXT; + scope_group_id BIGINT; + BEGIN + -- Only process active content_access records + IF NEW.status = 'active' THEN + -- Determine the scope based on what the content_access grants + + -- Track access: scope format is 'track:' + IF NEW.track_id IS NOT NULL THEN + scope_string := 'track:' || NEW.track_id; + + INSERT INTO user_scopes (user_id, scope, expires_at, source_kind, source_id, created_at, updated_at) + VALUES (NEW.user_id, scope_string, NEW.expires_at, 'grant', NEW.id, NOW(), NOW()) + ON CONFLICT (user_id, scope) + DO UPDATE SET + expires_at = CASE + WHEN user_scopes.expires_at IS NULL OR NEW.expires_at IS NULL THEN NULL + WHEN NEW.expires_at > user_scopes.expires_at THEN NEW.expires_at + ELSE user_scopes.expires_at + END, + updated_at = NOW(); + END IF; + + -- Group role access: scope format is 'group_role::' + IF NEW.group_role_id IS NOT NULL THEN + -- Use group_id if available, otherwise fall back to granted_by_group_id + scope_group_id := COALESCE(NEW.group_id, NEW.granted_by_group_id); + IF scope_group_id IS NULL THEN + RAISE WARNING 'Cannot create group role scope: missing group_id and granted_by_group_id for content_access %', NEW.id; + ELSE + scope_string := 'group_role:' || scope_group_id || ':' || NEW.group_role_id; + + INSERT INTO user_scopes (user_id, scope, expires_at, source_kind, source_id, created_at, updated_at) + VALUES (NEW.user_id, scope_string, NEW.expires_at, 'grant', NEW.id, NOW(), NOW()) + ON CONFLICT (user_id, scope) + DO UPDATE SET + expires_at = CASE + WHEN user_scopes.expires_at IS NULL OR NEW.expires_at IS NULL THEN NULL + WHEN NEW.expires_at > user_scopes.expires_at THEN NEW.expires_at + ELSE user_scopes.expires_at + END, + updated_at = NOW(); + END IF; + END IF; + + -- Common role access: scope format is 'common_role::' + IF NEW.common_role_id IS NOT NULL THEN + -- Use group_id if available, otherwise fall back to granted_by_group_id + scope_group_id := COALESCE(NEW.group_id, NEW.granted_by_group_id); + IF scope_group_id IS NULL THEN + RAISE WARNING 'Cannot create common role scope: missing group_id and granted_by_group_id for content_access %', NEW.id; + ELSE + scope_string := 'common_role:' || scope_group_id || ':' || NEW.common_role_id; + + INSERT INTO user_scopes (user_id, scope, expires_at, source_kind, source_id, created_at, updated_at) + VALUES (NEW.user_id, scope_string, NEW.expires_at, 'grant', NEW.id, NOW(), NOW()) + ON CONFLICT (user_id, scope) + DO UPDATE SET + expires_at = CASE + WHEN user_scopes.expires_at IS NULL OR NEW.expires_at IS NULL THEN NULL + WHEN NEW.expires_at > user_scopes.expires_at THEN NEW.expires_at + ELSE user_scopes.expires_at + END, + updated_at = NOW(); + END IF; + END IF; + + -- Group access: scope format is 'group:' + IF NEW.track_id IS NULL AND NEW.group_role_id IS NULL AND NEW.common_role_id IS NULL AND NEW.granted_by_group_id IS NOT NULL THEN + scope_string := 'group:' || NEW.granted_by_group_id; + + INSERT INTO user_scopes (user_id, scope, expires_at, source_kind, source_id, created_at, updated_at) + VALUES (NEW.user_id, scope_string, NEW.expires_at, 'grant', NEW.id, NOW(), NOW()) + ON CONFLICT (user_id, scope) + DO UPDATE SET + expires_at = CASE + WHEN user_scopes.expires_at IS NULL OR NEW.expires_at IS NULL THEN NULL + WHEN NEW.expires_at > user_scopes.expires_at THEN NEW.expires_at + ELSE user_scopes.expires_at + END, + updated_at = NOW(); + END IF; + ELSE + -- If status is not active (revoked/expired), remove the scope + IF NEW.track_id IS NOT NULL THEN + scope_string := 'track:' || NEW.track_id; + DELETE FROM user_scopes + WHERE user_id = NEW.user_id + AND scope = scope_string + AND source_kind = 'grant' + AND source_id = NEW.id; + END IF; + + IF NEW.group_role_id IS NOT NULL THEN + scope_group_id := COALESCE(NEW.group_id, NEW.granted_by_group_id); + IF scope_group_id IS NOT NULL THEN + scope_string := 'group_role:' || scope_group_id || ':' || NEW.group_role_id; + DELETE FROM user_scopes + WHERE user_id = NEW.user_id + AND scope = scope_string + AND source_kind = 'grant' + AND source_id = NEW.id; + END IF; + END IF; + + IF NEW.common_role_id IS NOT NULL THEN + scope_group_id := COALESCE(NEW.group_id, NEW.granted_by_group_id); + IF scope_group_id IS NOT NULL THEN + scope_string := 'common_role:' || scope_group_id || ':' || NEW.common_role_id; + DELETE FROM user_scopes + WHERE user_id = NEW.user_id + AND scope = scope_string + AND source_kind = 'grant' + AND source_id = NEW.id; + END IF; + END IF; + + IF NEW.track_id IS NULL AND NEW.group_role_id IS NULL AND NEW.common_role_id IS NULL AND NEW.granted_by_group_id IS NOT NULL THEN + scope_string := 'group:' || NEW.granted_by_group_id; + DELETE FROM user_scopes + WHERE user_id = NEW.user_id + AND scope = scope_string + AND source_kind = 'grant' + AND source_id = NEW.id; + END IF; + END IF; + + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) + + // Update the sync_content_access_expires_at function to handle both role types + await knex.raw(` + CREATE OR REPLACE FUNCTION sync_content_access_expires_at() + RETURNS TRIGGER AS $$ + DECLARE + latest_expires_at TIMESTAMP WITH TIME ZONE; + BEGIN + -- Track-level access: sync to tracks_users + IF NEW.track_id IS NOT NULL THEN + UPDATE tracks_users + SET access_granted = true, updated_at = NOW() + WHERE user_id = NEW.user_id AND track_id = NEW.track_id; + END IF; + + -- Group role-level access: sync to group_memberships_group_roles and group_memberships + IF NEW.group_role_id IS NOT NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at + FROM content_access + WHERE user_id = NEW.user_id + AND group_role_id = NEW.group_role_id + AND granted_by_group_id = NEW.granted_by_group_id + AND status = 'active'; + UPDATE group_memberships_group_roles + SET expires_at = latest_expires_at, updated_at = NOW() + WHERE user_id = NEW.user_id + AND group_id = NEW.granted_by_group_id + AND group_role_id = NEW.group_role_id; + UPDATE group_memberships + SET expires_at = latest_expires_at, updated_at = NOW() + WHERE user_id = NEW.user_id + AND group_id = NEW.granted_by_group_id; + END IF; + + -- Common role-level access: sync to group_memberships_common_roles and group_memberships + IF NEW.common_role_id IS NOT NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at + FROM content_access + WHERE user_id = NEW.user_id + AND common_role_id = NEW.common_role_id + AND granted_by_group_id = NEW.granted_by_group_id + AND status = 'active'; + -- Note: We don't update group_memberships_common_roles expires_at directly + -- as it's managed separately, but we update group_memberships + UPDATE group_memberships + SET expires_at = latest_expires_at, updated_at = NOW() + WHERE user_id = NEW.user_id + AND group_id = NEW.granted_by_group_id; + END IF; + + -- Group-level access: sync to group_memberships + IF NEW.track_id IS NULL AND NEW.group_role_id IS NULL AND NEW.common_role_id IS NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at + FROM content_access + WHERE user_id = NEW.user_id + AND granted_by_group_id = NEW.granted_by_group_id + AND track_id IS NULL + AND group_role_id IS NULL + AND common_role_id IS NULL + AND status = 'active'; + UPDATE group_memberships + SET expires_at = latest_expires_at, updated_at = NOW() + WHERE user_id = NEW.user_id + AND group_id = NEW.granted_by_group_id; + END IF; + + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) + + // Update the clear_content_access_expires_at function + await knex.raw(` + CREATE OR REPLACE FUNCTION clear_content_access_expires_at() + RETURNS TRIGGER AS $$ + DECLARE + latest_expires_at TIMESTAMP WITH TIME ZONE; + BEGIN + -- Track-level access: clear from tracks_users + IF NEW.track_id IS NOT NULL THEN + UPDATE tracks_users + SET access_granted = false, updated_at = NOW() + WHERE user_id = NEW.user_id AND track_id = NEW.track_id; + END IF; + + -- Group role-level access: clear from group_memberships_group_roles and group_memberships + IF NEW.group_role_id IS NOT NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at + FROM content_access + WHERE user_id = NEW.user_id + AND group_role_id = NEW.group_role_id + AND granted_by_group_id = NEW.granted_by_group_id + AND status = 'active' + AND id != NEW.id; + UPDATE group_memberships_group_roles + SET expires_at = latest_expires_at, updated_at = NOW() + WHERE user_id = NEW.user_id + AND group_id = NEW.granted_by_group_id + AND group_role_id = NEW.group_role_id; + UPDATE group_memberships + SET expires_at = latest_expires_at, updated_at = NOW() + WHERE user_id = NEW.user_id + AND group_id = NEW.granted_by_group_id; + END IF; + + -- Common role-level access: clear from group_memberships + IF NEW.common_role_id IS NOT NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at + FROM content_access + WHERE user_id = NEW.user_id + AND common_role_id = NEW.common_role_id + AND granted_by_group_id = NEW.granted_by_group_id + AND status = 'active' + AND id != NEW.id; + UPDATE group_memberships + SET expires_at = latest_expires_at, updated_at = NOW() + WHERE user_id = NEW.user_id + AND group_id = NEW.granted_by_group_id; + END IF; + + -- Group-level access: clear from group_memberships + IF NEW.track_id IS NULL AND NEW.group_role_id IS NULL AND NEW.common_role_id IS NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at + FROM content_access + WHERE user_id = NEW.user_id + AND granted_by_group_id = NEW.granted_by_group_id + AND track_id IS NULL + AND group_role_id IS NULL + AND common_role_id IS NULL + AND status = 'active' + AND id != NEW.id; + UPDATE group_memberships + SET expires_at = latest_expires_at, updated_at = NOW() + WHERE user_id = NEW.user_id + AND group_id = NEW.granted_by_group_id; + END IF; + + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) +} + +exports.down = async function (knex) { + // Restore the old role_id column + await knex.schema.table('content_access', table => { + table.integer('role_id').unsigned().references('id').inTable('groups_roles').nullable() + }) + + // Migrate group_role_id back to role_id + await knex.raw(` + UPDATE content_access + SET role_id = group_role_id + WHERE group_role_id IS NOT NULL + `) + + // Add index for role_id + await knex.schema.table('content_access', table => { + table.index(['role_id']) + }) + + // Drop the new columns and their indexes + await knex.schema.table('content_access', table => { + table.dropIndex(['common_role_id']) + table.dropIndex(['group_role_id']) + table.dropForeign(['common_role_id']) + table.dropForeign(['group_role_id']) + table.dropColumn('common_role_id') + table.dropColumn('group_role_id') + }) + + // Restore the old trigger functions (reverting to role_id only) + await knex.raw(` + CREATE OR REPLACE FUNCTION compute_user_scopes_from_content_access() + RETURNS TRIGGER AS $$ + DECLARE + scope_string TEXT; + BEGIN + IF NEW.status = 'active' THEN + IF NEW.track_id IS NOT NULL THEN + scope_string := 'track:' || NEW.track_id; + INSERT INTO user_scopes (user_id, scope, expires_at, source_kind, source_id, created_at, updated_at) + VALUES (NEW.user_id, scope_string, NEW.expires_at, 'grant', NEW.id, NOW(), NOW()) + ON CONFLICT (user_id, scope) + DO UPDATE SET + expires_at = CASE + WHEN user_scopes.expires_at IS NULL OR NEW.expires_at IS NULL THEN NULL + WHEN NEW.expires_at > user_scopes.expires_at THEN NEW.expires_at + ELSE user_scopes.expires_at + END, + updated_at = NOW(); + END IF; + + IF NEW.role_id IS NOT NULL THEN + scope_string := 'group_role:' || NEW.role_id; + INSERT INTO user_scopes (user_id, scope, expires_at, source_kind, source_id, created_at, updated_at) + VALUES (NEW.user_id, scope_string, NEW.expires_at, 'grant', NEW.id, NOW(), NOW()) + ON CONFLICT (user_id, scope) + DO UPDATE SET + expires_at = CASE + WHEN user_scopes.expires_at IS NULL OR NEW.expires_at IS NULL THEN NULL + WHEN NEW.expires_at > user_scopes.expires_at THEN NEW.expires_at + ELSE user_scopes.expires_at + END, + updated_at = NOW(); + END IF; + + IF NEW.track_id IS NULL AND NEW.role_id IS NULL AND NEW.granted_by_group_id IS NOT NULL THEN + scope_string := 'group:' || NEW.granted_by_group_id; + INSERT INTO user_scopes (user_id, scope, expires_at, source_kind, source_id, created_at, updated_at) + VALUES (NEW.user_id, scope_string, NEW.expires_at, 'grant', NEW.id, NOW(), NOW()) + ON CONFLICT (user_id, scope) + DO UPDATE SET + expires_at = CASE + WHEN user_scopes.expires_at IS NULL OR NEW.expires_at IS NULL THEN NULL + WHEN NEW.expires_at > user_scopes.expires_at THEN NEW.expires_at + ELSE user_scopes.expires_at + END, + updated_at = NOW(); + END IF; + ELSE + IF NEW.track_id IS NOT NULL THEN + scope_string := 'track:' || NEW.track_id; + DELETE FROM user_scopes + WHERE user_id = NEW.user_id + AND scope = scope_string + AND source_kind = 'grant' + AND source_id = NEW.id; + END IF; + + IF NEW.role_id IS NOT NULL THEN + scope_string := 'group_role:' || NEW.role_id; + DELETE FROM user_scopes + WHERE user_id = NEW.user_id + AND scope = scope_string + AND source_kind = 'grant' + AND source_id = NEW.id; + END IF; + + IF NEW.track_id IS NULL AND NEW.role_id IS NULL AND NEW.granted_by_group_id IS NOT NULL THEN + scope_string := 'group:' || NEW.granted_by_group_id; + DELETE FROM user_scopes + WHERE user_id = NEW.user_id + AND scope = scope_string + AND source_kind = 'grant' + AND source_id = NEW.id; + END IF; + END IF; + + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) + + await knex.raw(` + CREATE OR REPLACE FUNCTION sync_content_access_expires_at() + RETURNS TRIGGER AS $$ + DECLARE + latest_expires_at TIMESTAMP WITH TIME ZONE; + BEGIN + IF NEW.track_id IS NOT NULL THEN + UPDATE tracks_users + SET access_granted = true, updated_at = NOW() + WHERE user_id = NEW.user_id AND track_id = NEW.track_id; + END IF; + + IF NEW.role_id IS NOT NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at FROM content_access WHERE user_id = NEW.user_id AND role_id = NEW.role_id AND granted_by_group_id = NEW.granted_by_group_id AND status = 'active'; + UPDATE group_memberships_group_roles SET expires_at = latest_expires_at, updated_at = NOW() WHERE user_id = NEW.user_id AND group_id = NEW.granted_by_group_id AND group_role_id = NEW.role_id; + UPDATE group_memberships SET expires_at = latest_expires_at, updated_at = NOW() WHERE user_id = NEW.user_id AND group_id = NEW.granted_by_group_id; + END IF; + + IF NEW.track_id IS NULL AND NEW.role_id IS NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at FROM content_access WHERE user_id = NEW.user_id AND granted_by_group_id = NEW.granted_by_group_id AND track_id IS NULL AND role_id IS NULL AND status = 'active'; + UPDATE group_memberships SET expires_at = latest_expires_at, updated_at = NOW() WHERE user_id = NEW.user_id AND group_id = NEW.granted_by_group_id; + END IF; + + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) + + await knex.raw(` + CREATE OR REPLACE FUNCTION clear_content_access_expires_at() + RETURNS TRIGGER AS $$ + DECLARE + latest_expires_at TIMESTAMP WITH TIME ZONE; + BEGIN + IF NEW.track_id IS NOT NULL THEN + UPDATE tracks_users + SET access_granted = false, updated_at = NOW() + WHERE user_id = NEW.user_id AND track_id = NEW.track_id; + END IF; + + IF NEW.role_id IS NOT NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at FROM content_access WHERE user_id = NEW.user_id AND role_id = NEW.role_id AND granted_by_group_id = NEW.granted_by_group_id AND status = 'active' AND id != NEW.id; + UPDATE group_memberships_group_roles SET expires_at = latest_expires_at, updated_at = NOW() WHERE user_id = NEW.user_id AND group_id = NEW.granted_by_group_id AND group_role_id = NEW.role_id; + UPDATE group_memberships SET expires_at = latest_expires_at, updated_at = NOW() WHERE user_id = NEW.user_id AND group_id = NEW.granted_by_group_id; + END IF; + + IF NEW.track_id IS NULL AND NEW.role_id IS NULL THEN + SELECT MAX(expires_at) INTO latest_expires_at FROM content_access WHERE user_id = NEW.user_id AND granted_by_group_id = NEW.granted_by_group_id AND track_id IS NULL AND role_id IS NULL AND status = 'active' AND id != NEW.id; + UPDATE group_memberships SET expires_at = latest_expires_at, updated_at = NOW() WHERE user_id = NEW.user_id AND group_id = NEW.granted_by_group_id; + END IF; + + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) +} diff --git a/apps/backend/migrations/schema.sql b/apps/backend/migrations/schema.sql index 087cb89f9d..be68dace19 100644 --- a/apps/backend/migrations/schema.sql +++ b/apps/backend/migrations/schema.sql @@ -1515,6 +1515,11 @@ CREATE TABLE public.groups ( purpose text, welcome_page text, website_url text, + stripe_account_id bigint, + stripe_charges_enabled boolean DEFAULT false, + stripe_payouts_enabled boolean DEFAULT false, + stripe_details_submitted boolean DEFAULT false, + paywall boolean DEFAULT false, calendar_token character varying(255) ); @@ -1599,10 +1604,18 @@ CREATE TABLE public.groups_roles ( active boolean, created_at timestamp with time zone, updated_at timestamp with time zone, - description character varying(255) + description character varying(255), + scopes jsonb ); +-- +-- Name: COLUMN groups_roles.scopes; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.groups_roles.scopes IS 'Array of scope strings that this role grants'; + + -- -- Name: groups_roles_id_seq; Type: SEQUENCE; Schema: public; Owner: - -- @@ -2620,7 +2633,6 @@ CREATE TABLE public.users ( location character varying(255), url character varying(255), tagline character varying(255), - stripe_account_id bigint, location_id bigint, contact_email character varying(255), contact_phone character varying(255), @@ -2971,6 +2983,128 @@ CREATE SEQUENCE public.stripe_accounts_id_seq ALTER SEQUENCE public.stripe_accounts_id_seq OWNED BY public.stripe_accounts.id; +-- +-- Name: stripe_products_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.stripe_products_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: stripe_products; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.stripe_products ( + id bigint DEFAULT nextval('public.stripe_products_id_seq'::regclass) NOT NULL, + group_id bigint NOT NULL, + stripe_product_id character varying(255) NOT NULL, + stripe_price_id character varying(255) NOT NULL, + name character varying(255) NOT NULL, + description text, + price_in_cents integer NOT NULL, + currency character varying(3) NOT NULL DEFAULT 'usd'::character varying, + track_id bigint, + access_grants jsonb DEFAULT '{}'::jsonb, + renewal_policy character varying(20) DEFAULT 'manual'::character varying, + duration character varying(20), + publish_status character varying(20) DEFAULT 'unpublished'::character varying, + created_at timestamp with time zone, + updated_at timestamp with time zone +); + + +-- +-- Name: stripe_products_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.stripe_products_id_seq OWNED BY public.stripe_products.id; + + +-- +-- Name: content_access_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.content_access_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: content_access; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.content_access ( + id bigint DEFAULT nextval('public.content_access_id_seq'::regclass) NOT NULL, + user_id bigint NOT NULL, + granted_by_group_id bigint NOT NULL, + group_id bigint, + product_id bigint, + track_id integer, + role_id integer, + access_type character varying(50) NOT NULL, + stripe_session_id character varying(255), + stripe_subscription_id character varying(255), + status character varying(50) NOT NULL DEFAULT 'active'::character varying, + granted_by_id bigint, + expires_at timestamp with time zone, + metadata jsonb DEFAULT '{}'::jsonb, + created_at timestamp with time zone, + updated_at timestamp with time zone +); + + +-- +-- Name: content_access_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.content_access_id_seq OWNED BY public.content_access.id; + + +-- +-- Name: user_scopes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_scopes ( + user_id bigint NOT NULL, + scope character varying(255) NOT NULL, + expires_at timestamp with time zone, + source_kind character varying(255) NOT NULL, + source_id bigint NOT NULL, + created_at timestamp with time zone, + updated_at timestamp with time zone, + CONSTRAINT user_scopes_pkey PRIMARY KEY (user_id, scope) +); + + +-- +-- Name: COLUMN user_scopes.expires_at; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.user_scopes.expires_at IS 'Earliest ends_at among sources, null means never expires'; + + +-- +-- Name: COLUMN user_scopes.source_kind; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.user_scopes.source_kind IS 'Type of source: grant or role'; + + +-- +-- Name: COLUMN user_scopes.source_id; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.user_scopes.source_id IS 'ID of the content_access grant or group_memberships_group_roles record'; + + -- -- Name: tags; Type: TABLE; Schema: public; Owner: - -- @@ -3061,7 +3195,8 @@ CREATE TABLE public.tracks ( num_people_completed integer DEFAULT 0, completion_role_id bigint, completion_role_type character varying(255), - action_descriptor character varying(255) + action_descriptor character varying(255), + access_controlled boolean DEFAULT false ); @@ -5562,6 +5697,120 @@ CREATE INDEX user_verification_codes_email_index ON public.user_verification_cod CREATE INDEX zapier_triggers_groups_zapier_trigger_id_index ON public.zapier_triggers_groups USING btree (zapier_trigger_id); +-- +-- Name: stripe_products_group_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX stripe_products_group_id_index ON public.stripe_products USING btree (group_id); + + +-- +-- Name: stripe_products_stripe_product_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX stripe_products_stripe_product_id_index ON public.stripe_products USING btree (stripe_product_id); + + +-- +-- Name: content_access_user_id_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX content_access_user_id_status_index ON public.content_access USING btree (user_id, status); + + +-- +-- Name: content_access_group_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX content_access_group_id_index ON public.content_access USING btree (group_id); + + +-- +-- Name: content_access_product_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX content_access_product_id_index ON public.content_access USING btree (product_id); + + +-- +-- Name: content_access_track_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX content_access_track_id_index ON public.content_access USING btree (track_id); + + +-- +-- Name: content_access_role_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX content_access_role_id_index ON public.content_access USING btree (role_id); + + +-- +-- Name: content_access_stripe_session_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX content_access_stripe_session_id_index ON public.content_access USING btree (stripe_session_id); + + +-- +-- Name: content_access_access_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX content_access_access_type_index ON public.content_access USING btree (access_type); + + +-- +-- Name: content_access_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX content_access_status_index ON public.content_access USING btree (status); + + +-- +-- Name: content_access_stripe_subscription_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX content_access_stripe_subscription_id_index ON public.content_access USING btree (stripe_subscription_id) WHERE (stripe_subscription_id IS NOT NULL); + + +-- +-- Name: user_scopes_user_id_scope_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_scopes_user_id_scope_index ON public.user_scopes USING btree (user_id, scope); + + +-- +-- Name: user_scopes_expires_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_scopes_expires_at_index ON public.user_scopes USING btree (expires_at) WHERE (expires_at IS NOT NULL); + + +-- +-- Name: user_scopes_source_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_scopes_source_index ON public.user_scopes USING btree (source_kind, source_id); + + +-- +-- Name: stripe_products stripe_products_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.stripe_products + ADD CONSTRAINT stripe_products_pkey PRIMARY KEY (id); + + +-- +-- Name: content_access content_access_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.content_access + ADD CONSTRAINT content_access_pkey PRIMARY KEY (id); + + -- -- Name: activities activities_contribution_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -6514,6 +6763,14 @@ ALTER TABLE ONLY public.groups ADD CONSTRAINT groups_created_by_id_foreign FOREIGN KEY (created_by_id) REFERENCES public.users(id) DEFERRABLE INITIALLY DEFERRED; +-- +-- Name: groups groups_stripe_account_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.groups + ADD CONSTRAINT groups_stripe_account_id_foreign FOREIGN KEY (stripe_account_id) REFERENCES public.stripe_accounts(id) DEFERRABLE INITIALLY DEFERRED; + + -- -- Name: groups groups_location_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -6578,6 +6835,86 @@ ALTER TABLE ONLY public.groups_tracks ADD CONSTRAINT groups_tracks_track_id_foreign FOREIGN KEY (track_id) REFERENCES public.tracks(id) DEFERRABLE INITIALLY DEFERRED; +-- +-- Name: stripe_products stripe_products_group_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.stripe_products + ADD CONSTRAINT stripe_products_group_id_foreign FOREIGN KEY (group_id) REFERENCES public.groups(id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: stripe_products stripe_products_track_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.stripe_products + ADD CONSTRAINT stripe_products_track_id_foreign FOREIGN KEY (track_id) REFERENCES public.tracks(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: content_access content_access_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.content_access + ADD CONSTRAINT content_access_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.users(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: content_access content_access_granted_by_group_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.content_access + ADD CONSTRAINT content_access_granted_by_group_id_foreign FOREIGN KEY (granted_by_group_id) REFERENCES public.groups(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: content_access content_access_group_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.content_access + ADD CONSTRAINT content_access_group_id_foreign FOREIGN KEY (group_id) REFERENCES public.groups(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: content_access content_access_product_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.content_access + ADD CONSTRAINT content_access_product_id_foreign FOREIGN KEY (product_id) REFERENCES public.stripe_products(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: content_access content_access_track_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.content_access + ADD CONSTRAINT content_access_track_id_foreign FOREIGN KEY (track_id) REFERENCES public.tracks(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: content_access content_access_role_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.content_access + ADD CONSTRAINT content_access_role_id_foreign FOREIGN KEY (role_id) REFERENCES public.groups_roles(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: content_access content_access_granted_by_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.content_access + ADD CONSTRAINT content_access_granted_by_id_foreign FOREIGN KEY (granted_by_id) REFERENCES public.users(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: user_scopes user_scopes_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_scopes + ADD CONSTRAINT user_scopes_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.users(id); + + -- -- Name: group_join_questions_answers join_request_question_answers_join_request_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -7090,14 +7427,6 @@ ALTER TABLE ONLY public.users ADD CONSTRAINT users_location_id_foreign FOREIGN KEY (location_id) REFERENCES public.locations(id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED; --- --- Name: users users_stripe_account_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.users - ADD CONSTRAINT users_stripe_account_id_foreign FOREIGN KEY (stripe_account_id) REFERENCES public.stripe_accounts(id); - - -- -- Name: zapier_triggers_groups zapier_triggers_groups_group_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -7122,6 +7451,168 @@ ALTER TABLE ONLY public.zapier_triggers ADD CONSTRAINT zapier_triggers_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.users(id) DEFERRABLE INITIALLY DEFERRED; +-- +-- Name: compute_user_scopes_from_content_access(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE OR REPLACE FUNCTION compute_user_scopes_from_content_access() RETURNS TRIGGER AS $$ +DECLARE + scope_string TEXT; +BEGIN + IF NEW.status = 'active' THEN + IF NEW.track_id IS NOT NULL THEN + scope_string := 'track:' || NEW.track_id; + INSERT INTO user_scopes (user_id, scope, expires_at, source_kind, source_id, created_at, updated_at) + VALUES (NEW.user_id, scope_string, NEW.expires_at, 'grant', NEW.id, NOW(), NOW()) + ON CONFLICT (user_id, scope) + DO UPDATE SET + expires_at = CASE + WHEN user_scopes.expires_at IS NULL OR NEW.expires_at IS NULL THEN NULL + WHEN NEW.expires_at > user_scopes.expires_at THEN NEW.expires_at + ELSE user_scopes.expires_at + END, + updated_at = NOW(); + END IF; + IF NEW.role_id IS NOT NULL THEN + scope_string := 'group_role:' || NEW.role_id; + INSERT INTO user_scopes (user_id, scope, expires_at, source_kind, source_id, created_at, updated_at) + VALUES (NEW.user_id, scope_string, NEW.expires_at, 'grant', NEW.id, NOW(), NOW()) + ON CONFLICT (user_id, scope) + DO UPDATE SET + expires_at = CASE + WHEN user_scopes.expires_at IS NULL OR NEW.expires_at IS NULL THEN NULL + WHEN NEW.expires_at > user_scopes.expires_at THEN NEW.expires_at + ELSE user_scopes.expires_at + END, + updated_at = NOW(); + END IF; + IF NEW.track_id IS NULL AND NEW.role_id IS NULL AND NEW.granted_by_group_id IS NOT NULL THEN + scope_string := 'group:' || NEW.granted_by_group_id; + INSERT INTO user_scopes (user_id, scope, expires_at, source_kind, source_id, created_at, updated_at) + VALUES (NEW.user_id, scope_string, NEW.expires_at, 'grant', NEW.id, NOW(), NOW()) + ON CONFLICT (user_id, scope) + DO UPDATE SET + expires_at = CASE + WHEN user_scopes.expires_at IS NULL OR NEW.expires_at IS NULL THEN NULL + WHEN NEW.expires_at > user_scopes.expires_at THEN NEW.expires_at + ELSE user_scopes.expires_at + END, + updated_at = NOW(); + END IF; + ELSE + IF NEW.track_id IS NOT NULL THEN + scope_string := 'track:' || NEW.track_id; + DELETE FROM user_scopes + WHERE user_id = NEW.user_id + AND scope = scope_string + AND source_kind = 'grant' + AND source_id = NEW.id; + END IF; + IF NEW.role_id IS NOT NULL THEN + scope_string := 'group_role:' || NEW.role_id; + DELETE FROM user_scopes + WHERE user_id = NEW.user_id + AND scope = scope_string + AND source_kind = 'grant' + AND source_id = NEW.id; + END IF; + IF NEW.track_id IS NULL AND NEW.role_id IS NULL AND NEW.granted_by_group_id IS NOT NULL THEN + scope_string := 'group:' || NEW.granted_by_group_id; + DELETE FROM user_scopes + WHERE user_id = NEW.user_id + AND scope = scope_string + AND source_kind = 'grant' + AND source_id = NEW.id; + END IF; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + + +-- +-- Name: compute_user_scopes_from_role(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE OR REPLACE FUNCTION compute_user_scopes_from_role() RETURNS TRIGGER AS $$ +DECLARE + role_scopes JSONB; + scope_string TEXT; +BEGIN + IF NEW.active = true THEN + SELECT scopes INTO role_scopes + FROM groups_roles + WHERE id = NEW.group_role_id; + IF role_scopes IS NOT NULL THEN + FOR scope_string IN SELECT jsonb_array_elements_text(role_scopes) + LOOP + INSERT INTO user_scopes (user_id, scope, expires_at, source_kind, source_id, created_at, updated_at) + VALUES (NEW.user_id, scope_string, NULL, 'role', NEW.id, NOW(), NOW()) + ON CONFLICT (user_id, scope) + DO UPDATE SET + updated_at = NOW(); + END LOOP; + END IF; + ELSE + SELECT scopes INTO role_scopes + FROM groups_roles + WHERE id = NEW.group_role_id; + IF role_scopes IS NOT NULL THEN + FOR scope_string IN SELECT jsonb_array_elements_text(role_scopes) + LOOP + DELETE FROM user_scopes + WHERE user_id = NEW.user_id + AND scope = scope_string + AND source_kind = 'role' + AND source_id = NEW.id; + END LOOP; + END IF; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + + +-- +-- Name: content_access_user_scopes_sync; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER content_access_user_scopes_sync + AFTER INSERT OR UPDATE ON public.content_access + FOR EACH ROW + EXECUTE FUNCTION compute_user_scopes_from_content_access(); + + +-- +-- Name: content_access_user_scopes_delete; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER content_access_user_scopes_delete + AFTER DELETE ON public.content_access + FOR EACH ROW + EXECUTE FUNCTION compute_user_scopes_from_content_access(); + + +-- +-- Name: group_role_assignment_user_scopes_sync; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER group_role_assignment_user_scopes_sync + AFTER INSERT OR UPDATE ON public.group_memberships_group_roles + FOR EACH ROW + EXECUTE FUNCTION compute_user_scopes_from_role(); + + +-- +-- Name: group_role_assignment_user_scopes_delete; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER group_role_assignment_user_scopes_delete + AFTER DELETE ON public.group_memberships_group_roles + FOR EACH ROW + EXECUTE FUNCTION compute_user_scopes_from_role(); + + -- -- PostgreSQL database dump complete -- diff --git a/apps/backend/package.json b/apps/backend/package.json index db69e7bd44..1b6dce76e7 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -31,6 +31,7 @@ "migrate-make": "node ./node_modules/.bin/knex migrate:make", "debug": "node-debug app.js", "dev": "nf -j Procfile.dev -p 3001 start -t 1000", + "dev-stripe": "nf -j Procfile.dev-stripe -p 3001 start -t 1000", "dump": "pg_dump -Osx hylo | sed -e 's/; Tablespace: $//' -e 's/CREATE SCHEMA public;//' -e 's/ AS \"?column?\"//' -e \"s/'search_path', ''/'search_path', 'public'/\" | sed '/^CREATE PROCEDURE/,/^$$;/d' | sed '/^SET transaction_timeout = 0;$/d' > migrations/schema.sql", "dump-docker": "docker exec devcontainer-db-1 pg_dump -Osx hylo -U postgres | sed -e 's/; Tablespace: $//' -e 's/CREATE SCHEMA public;//' -e 's/ AS \"?column?\"//' -e \"s/'search_path', ''/'search_path', 'public'/\" | sed '/^CREATE PROCEDURE/,/^$$;/d' | sed '/^SET transaction_timeout = 0;$/d' > migrations/schema.sql", "test": "mocha -r test/setup/core -r @babel/register --exit", @@ -146,7 +147,7 @@ "sharp": "^0.30.4", "socket.io-emitter": "^3.1.1", "stream-buffers": "^3.0.2", - "stripe": "^6.15.0", + "stripe": "^17.5.0", "uuid": "^9.0.0", "validator": "^3.22.1", "wkx": "^0.5.0" @@ -182,6 +183,7 @@ "Comment", "CommentTag", "CommonRole", + "ContentAccess", "ContextWidget", "CookieConsent", "CustomView", @@ -243,6 +245,8 @@ "SavedSearch", "Search", "Skill", + "StripeService", + "StripeProduct", "Tag", "TagFollow", "Thank", diff --git a/apps/backend/test/setup/index.js b/apps/backend/test/setup/index.js index cbc5495488..278f9c67a9 100644 --- a/apps/backend/test/setup/index.js +++ b/apps/backend/test/setup/index.js @@ -1,13 +1,49 @@ +/* eslint-disable import/first */ process.env.NODE_ENV = 'test' +process.env.STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || 'sk_test_fake_key_for_testing_purposes' +process.env.STRIPE_API_KEY = process.env.STRIPE_API_KEY || 'sk_test_fake_api_key_for_testing_purposes' import nock from 'nock' import './core' +const mock = require('mock-require') const skiff = require('../../lib/skiff') const fs = require('fs') const path = require('path') const Promise = require('bluebird') const root = require('root-path') +// Mock Stripe before any modules are loaded +const mockStripe = function (apiKey) { + return { + accounts: { + create: async () => ({}), + retrieve: async () => ({}), + update: async () => ({}) + }, + accountLinks: { + create: async () => ({}) + }, + products: { + create: async () => ({}), + update: async () => ({}), + list: async () => ({}) + }, + prices: { + create: async () => ({}), + retrieve: async () => ({}) + }, + checkout: { + sessions: { + create: async () => ({}), + retrieve: async () => ({}) + } + } + } +} + +// Set up mock-require to intercept all Stripe imports +mock('stripe', mockStripe) + const TestSetup = function () { this.tables = [] this.initialized = false @@ -23,7 +59,7 @@ before(function (done) { global.sails = skiff.sails skiff.lift({ - log: {level: process.env.LOG_LEVEL || 'warn'}, + log: { level: process.env.LOG_LEVEL || 'warn' }, silent: true, start: function () { const { database } = bookshelf.knex.client.connectionSettings @@ -72,13 +108,45 @@ TestSetup.prototype.createSchema = function () { .then(() => bookshelf.knex.raw('create schema public').transacting(trx)) .then(() => { const script = fs.readFileSync(root('migrations/schema.sql')).toString() - return script.split(/\n/) + + // Remove comment lines + const cleaned = script.split(/\n/) .filter(line => !line.startsWith('--') && !line.startsWith('\\')) .join(' ') .replace(/\s+/g, ' ') - .split(/;\s?/) - .map(line => line.trim()) - .filter(line => line !== '') + + // Split by semicolon but preserve dollar-quoted strings + const commands = [] + let inDollarQuote = false + let currentCommand = '' + + for (let i = 0; i < cleaned.length; i++) { + const char = cleaned[i] + const nextChar = cleaned[i + 1] || '' + + // Check if we're entering or exiting a dollar-quoted string + if (char === '$' && nextChar === '$') { + inDollarQuote = !inDollarQuote + currentCommand += char + if (nextChar) currentCommand += cleaned[++i] + } else if (char === ';' && !inDollarQuote) { + currentCommand += char + const trimmed = currentCommand.trim() + if (trimmed) { + commands.push(trimmed) + } + currentCommand = '' + } else { + currentCommand += char + } + } + + // Add the last command if any + if (currentCommand.trim()) { + commands.push(currentCommand.trim()) + } + + return commands.filter(line => line !== '') }) .then(commands => { return Promise.map(commands, command => { @@ -94,6 +162,10 @@ TestSetup.prototype.createSchema = function () { TestSetup.prototype.clearDb = function () { if (!this.initialized) throw new Error('not initialized') return bookshelf.knex.transaction(trx => trx.raw('set constraints all deferred') + .then(() => { + // Delete from user_scopes first to avoid foreign key constraint violations + return trx.raw('delete from public.user_scopes') + }) .then(() => Promise.map(this.tables, table => { if (!['public.common_roles', 'public.responsibilities', 'public.common_roles_responsibilities', 'public.tags', 'public.platform_agreements'].includes(table)) { return trx.raw('delete from ' + table) } }))) } diff --git a/apps/backend/test/unit/lib/scopes.test.js b/apps/backend/test/unit/lib/scopes.test.js new file mode 100644 index 0000000000..1591d5956f --- /dev/null +++ b/apps/backend/test/unit/lib/scopes.test.js @@ -0,0 +1,150 @@ +import { expect } from 'chai' +import { + SCOPE_TYPES, + createScope, + parseScope, + isValidScope, + createGroupScope, + createTrackScope, + createGroupRoleScope, + isScopeOfType, + getEntityIdFromScope, + createScopesFromContentAccess +} from '../../../lib/scopes' + +describe('Scope Helper Functions', () => { + describe('createScope', () => { + it('creates a valid scope string', () => { + expect(createScope('group', 123)).to.equal('group:123') + expect(createScope('track', '456')).to.equal('track:456') + expect(createScope('group_role', 789)).to.equal('group_role:789') + }) + + it('throws error for invalid type', () => { + expect(() => createScope('invalid', 123)).to.throw('Invalid scope type') + }) + + it('throws error for missing entityId', () => { + expect(() => createScope('group')).to.throw('Entity ID is required') + expect(() => createScope('group', null)).to.throw('Entity ID is required') + }) + }) + + describe('parseScope', () => { + it('parses valid scope strings', () => { + expect(parseScope('group:123')).to.deep.equal({ type: 'group', entityId: '123' }) + expect(parseScope('track:456')).to.deep.equal({ type: 'track', entityId: '456' }) + expect(parseScope('group_role:789')).to.deep.equal({ type: 'group_role', entityId: '789' }) + }) + + it('returns null for invalid scope strings', () => { + expect(parseScope('invalid:123')).to.be.null + expect(parseScope('group')).to.be.null + expect(parseScope('group:')).to.be.null + expect(parseScope('group:123:extra')).to.be.null + expect(parseScope('')).to.be.null + expect(parseScope(null)).to.be.null + expect(parseScope(123)).to.be.null + }) + }) + + describe('isValidScope', () => { + it('validates scope strings', () => { + expect(isValidScope('group:123')).to.be.true + expect(isValidScope('track:456')).to.be.true + expect(isValidScope('group_role:789')).to.be.true + expect(isValidScope('invalid:123')).to.be.false + expect(isValidScope('group')).to.be.false + expect(isValidScope('')).to.be.false + }) + }) + + describe('createGroupScope', () => { + it('creates a group scope', () => { + expect(createGroupScope(123)).to.equal('group:123') + expect(createGroupScope('456')).to.equal('group:456') + }) + }) + + describe('createTrackScope', () => { + it('creates a track scope', () => { + expect(createTrackScope(123)).to.equal('track:123') + expect(createTrackScope('456')).to.equal('track:456') + }) + }) + + describe('createGroupRoleScope', () => { + it('creates a group role scope', () => { + expect(createGroupRoleScope(123)).to.equal('group_role:123') + expect(createGroupRoleScope('456')).to.equal('group_role:456') + }) + }) + + describe('isScopeOfType', () => { + it('checks if scope is of specific type', () => { + expect(isScopeOfType('group:123', 'group')).to.be.true + expect(isScopeOfType('track:456', 'track')).to.be.true + expect(isScopeOfType('group_role:789', 'group_role')).to.be.true + expect(isScopeOfType('group:123', 'track')).to.be.false + expect(isScopeOfType('invalid:123', 'group')).to.be.false + }) + }) + + describe('getEntityIdFromScope', () => { + it('extracts entity ID from scope', () => { + expect(getEntityIdFromScope('group:123')).to.equal('123') + expect(getEntityIdFromScope('track:456')).to.equal('456') + expect(getEntityIdFromScope('group_role:789')).to.equal('789') + expect(getEntityIdFromScope('invalid:123')).to.be.null + expect(getEntityIdFromScope('group')).to.be.null + }) + }) + + describe('createScopesFromContentAccess', () => { + it('creates scopes from content access object', () => { + const contentAccess = { + trackIds: [1, 2], + groupIds: [3] + } + + const scopes = createScopesFromContentAccess(contentAccess) + + // Note: Role scopes require groupId, so they cannot be created from accessGrants alone + // Roles are created from content_access records which have both groupRoleId/commonRoleId and groupId + expect(scopes).to.have.lengthOf(3) + expect(scopes).to.include('track:1') + expect(scopes).to.include('track:2') + expect(scopes).to.include('group:3') + }) + + it('handles empty content access', () => { + expect(createScopesFromContentAccess({})).to.deep.equal([]) + expect(createScopesFromContentAccess(null)).to.deep.equal([]) + expect(createScopesFromContentAccess(undefined)).to.deep.equal([]) + }) + + it('handles partial content access', () => { + const contentAccess = { + trackIds: [1] + } + + const scopes = createScopesFromContentAccess(contentAccess) + + expect(scopes).to.have.lengthOf(1) + expect(scopes).to.include('track:1') + }) + + it('ignores non-array values', () => { + const contentAccess = { + trackIds: 'not-an-array', + groupIds: [1] + } + + const scopes = createScopesFromContentAccess(contentAccess) + + expect(scopes).to.have.lengthOf(1) + expect(scopes).to.include('group:1') + }) + }) +}) + diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 2a3be51732..ab73f247f7 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -203,7 +203,7 @@ "kitType": "app", "alignDeps": { "requirements": [ - "react-native@0.70" + "react-native@0.77.3" ], "capabilities": [ "animation", diff --git a/apps/web/public/locales/en.json b/apps/web/public/locales/en.json index 36763fc987..6ddde7761a 100644 --- a/apps/web/public/locales/en.json +++ b/apps/web/public/locales/en.json @@ -49,10 +49,13 @@ "Access to your phone number.": "Access to your phone number.", "Access to your physical address.": "Access to your physical address.", "Access your profile, including your name and image.": "Access your profile, including your name and image.", + "Access Type": "Access Type", "Account": "Account", "Account Settings": "Account Settings", "Account already exists": "Account already exists", "Active": "Active", + "Active Members": "Active Members", + "Active Subscribers": "Active Subscribers", "Activity": "Activity", "Add": "Add", "Add Affiliation": "Add Affiliation", @@ -118,6 +121,7 @@ "Add {{actionDescriptor}}": "Add {{actionDescriptor}}", "Add {{something}} to Menu": "Add {{something}} to Menu", "Add {{submissionDescriptor}}": "Add {{submissionDescriptor}}", + "Admin Grant": "Admin Grant", "Admin Only": "Admin Only", "Administrators will always see exact location": "Administrators will always see exact location", "Advice": "Advice", @@ -130,7 +134,12 @@ "All Groups": "All Groups", "All Hylo group events you RSVP to": "All Hylo group events you RSVP to", "All My Groups": "All My Groups", + "All Offerings": "All Offerings", "All Posts": "All Posts", + "All Roles": "All Roles", + "All Status": "All Status", + "All Tracks": "All Tracks", + "All Types": "All Types", "All Posts Marked Public": "All Posts Marked Public", "All Topics": "All Topics", "All Views": "All Views", @@ -254,6 +263,8 @@ "Can you go?": "Can you go?", "Cancel": "Cancel", "Cancel Invite": "Cancel Invite", + "Choose a track...": "Choose a track...", + "Choose an offering...": "Choose an offering...", "Cancel Request": "Cancel Request", "Canceled": "Canceled", "Cannot allocate more than {{max}} tokens per submission": "Cannot allocate more than {{max}} tokens per submission", @@ -322,6 +333,8 @@ "Contact {{group.name}} to learn about their practices": "Contact {{group.name}} to learn about their practices", "Container": "Container", "Container Widget": "Container Widget", + "Content Access": "Content Access", + "Content Access Records": "Content Access Records", "Continue": "Continue", "Continue with Google": "Continue with Google", "Contribute": "Contribute", @@ -330,6 +343,7 @@ "Contributions so far: {{totalContributions}}": "Contributions so far: {{totalContributions}}", "Cookie Preferences": "Cookie Preferences", "Cooperatives": "Cooperatives", + "Could not determine group from invitation": "Could not determine group from invitation", "Copied!": "Copied!", "Copy": "Copy", "Copy Invite Link": "Copy Invite Link", @@ -457,6 +471,7 @@ "Edit {{submissionDescriptor}}": "Edit {{submissionDescriptor}}", "Editing Group Menu": "Editing Group Menu", "Editing coming soon": "Editing coming soon", + "Either your membership has lapsed or the group stewards have added a paywall to the group.": "Either your membership has lapsed or the group stewards have added a paywall to the group.", "Email": "Email", "Email address is not in a valid format": "Email address is not in a valid format", "Email address not found": "Email address not found", @@ -485,6 +500,8 @@ "Equipment sharing": "Equipment sharing", "Error": "Error", "Error leaving {{group_name}}": "Error leaving {{group_name}}", + "Error loading transactions": "Error loading transactions", + "Error loading subscriber data": "Error loading subscriber data", "Essential cookies": "Essential Cookies", "Event collaboration": "Event collaboration", "Event ended": "Event ended", @@ -496,7 +513,10 @@ "Example: \"I will not troll or be intentionally divisive\"": "Example: \"I will not troll or be intentionally divisive\"", "Example: \"I will only post content relevant to this group\"": "Example: \"I will only post content relevant to this group\"", "Expire": "Expire", + "Expired": "Expired", + "Expires": "Expires", "Explanation for Flagging": "Explanation for Flagging", + "Failed to grant access": "Failed to grant access", "Explore": "Explore", "Explore Groups": "Explore Groups", "Export Data": "Export Data", @@ -551,7 +571,15 @@ "Go to tracks settings": "Go to tracks settings", "Going": "Going", "Gradient of Agreement": "Gradient of Agreement", + "Granted": "Granted", + "Granted by": "Granted by", + "Grant Access": "Grant Access", + "Grant access form will be implemented here": "Grant access form will be implemented here", + "Grant users access to group content, tracks, or offerings": "Grant users access to group content, tracks, or offerings", + "Granting...": "Granting...", + "Granting group access will add the selected user as a member of this group.": "Granting group access will add the selected user as a member of this group.", "Group": "Group", + "Group Access": "Group Access", "Group Access Questions": "Group Access Questions", "Group Agreements": "Group Agreements", "Group Agreements broken": "Group Agreements broken", @@ -639,6 +667,7 @@ "Interested": "Interested", "Invalid code, please try again": "Invalid code, please try again", "Invalid email address": "Invalid email address", + "Invalid invitation": "Invalid invitation", "Invalid url. Please enter the full url for your {{network}} page.": "Invalid url. Please enter the full url for your {{network}} page.", "Invitations": "Invitations", "Invitations to Join New Groups": "Invitations to Join New Groups", @@ -659,6 +688,7 @@ "Is this resource still available?": "Is this resource still available?", "Join": "Join", "Join Hylo": "Join Hylo", + "Joined": "Joined", "Join Parent Groups": "Join Parent Groups", "Join Project": "Join Project", "Join Question Responses": "Join Question Responses", @@ -677,6 +707,8 @@ "Label": "Label", "Language": "Language", "Language Settings": "Language Settings", + "Lapsed": "Lapsed", + "Lapsed Members": "Lapsed Members", "Large Grid": "Large Grid", "Latest activity": "Latest activity", "Leave": "Leave", @@ -718,6 +750,10 @@ "Make your group discoverable in the Murmurations network": "Make your group discoverable in the Murmurations network", "Manage": "Manage", "Manage Notifications": "Manage Notifications", + "Manage in Stripe": "Manage in Stripe", + "Manage Subscription": "Manage Subscription", + "Manage your purchases and subscriptions below.": "Manage your purchases and subscriptions below.", + "Manage content access settings for your group offerings.": "Manage content access settings for your group offerings.", "Management Techniques: ": "Management Techniques: ", "Manual": "Manual", "Manually": "Manually", @@ -756,6 +792,8 @@ "Moderator": "Moderator", "Moderators": "Moderators", "Month": "Month", + "month": "month", + "Monthly Revenue": "Monthly Revenue", "Multiple Choice": "Multiple Choice", "Multiple people are typing...": "Multiple people are typing...", "Multiple votes allowed": "Multiple votes allowed", @@ -766,6 +804,7 @@ "My Invitations": "My Invitations", "My Invites": "My Invites", "My Posts": "My Posts", + "My Transactions": "My Transactions", "My Skills & Interests": "My Skills & Interests", "NO MORE RECENT ACTIVITY": "NO MORE RECENT ACTIVITY", "NOT": "NOT", @@ -810,8 +849,13 @@ "No Posts": "No Posts", "No active invitations to join new groups": "No active invitations to join new groups", "No chat topics found": "No chat topics found", + "No content access records found": "No content access records found", "No emoji": "No emoji", "No events": "No events", + "No offerings available": "No offerings available", + "No results found": "No results found", + "No transactions yet": "No transactions yet", + "No users found": "No users found", "No funding rounds match": "No funding rounds match", "No groups are members of {{group.name}} yet": "No groups are members of {{group.name}} yet", "No groups match": "No groups found", @@ -836,6 +880,8 @@ "No submissions yet": "No submissions yet", "No thanks": "No thanks", "No tracks match": "No tracks match", + "No active subscribers yet": "No active subscribers yet", + "No lapsed members": "No lapsed members", "No unread notifications": "No unread notifications", "No {{timeFrame}} events": "No {{timeFrame}} events", "None": "None", @@ -863,12 +909,15 @@ "ON": "ON", "Off": "Off", "Offensive": "Offensive", + "Offering": "Offering", + "Offerings": "Offerings", "Offer": "Offer", "Offers": "Offers", "Oh no, something went wrong! Check your internet connection and try again": "Oh no, something went wrong! Check your internet connection and try again", "Ok": "Ok", "On": "On", "One more step!": "One more step!", + "One-time purchase": "One-time purchase", "Only active": "Only active", "Only members of this group can see posts": "Only members of this group can see posts", "Only members of this group or direct child groups can see it": "Only members of this group or direct child groups can see it", @@ -908,11 +957,15 @@ "Override Name": "Override Name", "Overview": "Overview", "PERSONAL": "PERSONAL", + "Page": "Page", + "Paid Content": "Paid Content", "Parent Groups": "Parent Groups", "Participants": "Participants", "Password": "Password", "Password (at least 9 characters)": "Password (at least 9 characters)", "Passwords don't match": "Passwords don't match", + "Payment Type": "Payment Type", + "Paywall enabled": "Paywall enabled", "Passwords must be at least 9 characters long": "Passwords must be at least 9 characters long", "Passwords must be at least 9 characters long, and should be a mix of lower and upper case letters, numbers and symbols.": "Passwords must be at least 9 characters long, and should be a mix of lower and upper case letters, numbers and symbols.", "Past Events": "Past Events", @@ -933,6 +986,8 @@ "Pending requests to join other groups": "Pending requests to join other groups", "People": "People", "People can apply to join this group and must be approved": "People can apply to join this group and must be approved", + "Please select a user and an access type": "Please select a user and an access type", + "Please select at least one user and an access type": "Please select at least one user and an access type", "People want to join your group!": "People want to join your group!", "Phase Timeline": "Phase Timeline", "Pin": "Pin", @@ -953,6 +1008,10 @@ "Please enter your twitter name.": "Please enter your twitter name.", "Please provide either a `token` query string parameter or `accessCode` route param": "Please provide either a `token` query string parameter or `accessCode` route param", "Please provide either a token query string parameter or accessCode route param": "Please provide either a token query string parameter or accessCode route param", + "Please answer the following": "Please answer the following", + "Please log in with the correct account or request a new invitation.": "Please log in with the correct account or request a new invitation.", + "Please review and accept the following agreements": "Please review and accept the following agreements", + "Please review and accept the following agreements to join": "Please review and accept the following agreements to join", "Plural word used to describe group Stewards": "Plural word used to describe group Stewards", "Poll, Single Vote": "Poll, Single Vote", "Popular": "Popular", @@ -984,11 +1043,13 @@ "Project Members": "Project Members", "Projects": "Projects", "Projects help you and your group accomplish shared goals.": "Projects help you and your group accomplish shared goals.", + "Processing...": "Processing...", "Proposal options": "Proposal options", "Proposal template": "Proposal template", "Proposals": "Proposals", "Proposals require at least one option": "Proposals require at least one option", "Protected": "Protected", + "Purchased": "Purchased", "Public": "Public", "Public Group Link": "Public Group Link", "Public Groups": "Public Groups", @@ -1039,6 +1100,10 @@ "Remove from Menu": "Remove from Menu", "Remove from Round": "Remove from Round", "Remove from group as well": "Remove from group as well", + "Renews": "Renews", + "Refunded": "Refunded", + "Revoked": "Revoked", + "Role Access": "Role Access", "Remove post from group": "Remove post from group", "Remove post?": "Remove post?", "Reopen {{submissionDescriptorPlural}}": "Reopen {{submissionDescriptorPlural}}", @@ -1079,6 +1144,12 @@ "Restricted group": "Restricted group", "Return to All Groups": "Return to All Groups", "Revert": "Revert", + "Revoke Access": "Revoke Access", + "Revoked": "Revoked", + "Revoking...": "Revoking...", + "Refund": "Refund", + "Refund Purchase": "Refund Purchase", + "Role": "Role", "Roles & Badges": "Roles & Badges", "Roles in {{group}}": "Roles in {{group}}", "Round Complete!": "Round Complete!", @@ -1107,6 +1178,7 @@ "Search currencies or type a custom name": "Search currencies or type a custom name", "Search for a location...": "Search for a location...", "Search for badges or roles": "Search for badges or roles", + "Search by member name": "Search by member name", "Search for people, posts and comments": "Search for people, posts and comments", "Search for people...": "Search for people...", "Search for posts": "Search for posts", @@ -1164,9 +1236,18 @@ "Set the start and end dates for the submissions and voting phases. If no dates are set in advance, phases will start and stop by manually managing the round.": "Set the start and end dates for the submissions and voting phases. If no dates are set in advance, phases will start and stop by manually managing the round.", "Set track banner": "Set track banner", "Set your password here.": "Set your password here.", + "Select a user and choose what access to grant them.": "Select a user and choose what access to grant them.", + "Select Offering": "Select Offering", + "Select Track": "Select Track", + "Select User": "Select User", + "Search...": "Search...", + "Search for a user...": "Search for a user...", + "Searching...": "Searching...", "Settings": "Settings", "Setup": "Setup", "Share a Join Link": "Share a Join Link", + "Show Active": "Show Active", + "Show Lapsed": "Show Lapsed", "Share about who you are, your skills & interests": "Share about who you are, your skills & interests", "Show Answers": "Show Answers", "Show Less": "Show Less", @@ -1179,7 +1260,9 @@ "Show posts from child groups": "Show posts from child groups", "Show posts from child groups you are a member of": "Show posts from child groups you are a member of", "Show this welcome page to new members when they first land in the group. If this is turned off then they will go directly to your home view.": "Show this welcome page to new members when they first land in the group. If this is turned off then they will go directly to your home view.", + "Showing {{count}} of {{total}} records": "Showing {{count}} of {{total}} records", "Sign Up": "Sign Up", + "{{count}} user(s) selected": "{{count}} user(s) selected", "Sign in": "Sign in", "Sign in to Hylo": "Sign in to Hylo", "Sign up": "Sign up", @@ -1206,7 +1289,9 @@ "Starts {{date}}": "Starts {{date}}", "Starts: {{from}}": "Starts: {{from}}", "Stay connected, organized, and engaged with your group.": "Stay connected, organized, and engaged with your group.", + "Status": "Status", "Stream": "Stream", + "Stripe Purchase": "Stripe Purchase", "Subgroups": "Subgroups", "Submission": "Submission", "Submission Criteria": "Submission Criteria", @@ -1234,6 +1319,7 @@ "Submit": "Submit", "Submit Attachments and Complete": "Submit Attachments and Complete", "Subscribe": "Subscribe", + "Subscription": "Subscription", "Suggest a proposal for others to vote on": "Suggest a proposal for others to vote on", "Suggested": "Suggested", "Support (Intercom)": "Support (Intercom)", @@ -1279,6 +1365,10 @@ "This group is invitation only": "This group is invitation only", "This helps us provide better customer support and track bug reports. Your conversations are stored securely.": "This helps us provide better customer support and track bug reports. Your conversations are stored securely.", "This helps us understand how people use Hylo so we can improve the platform. Your data is anonymized and aggregated.": "This helps us understand how people use Hylo so we can improve the platform. Your data is anonymized and aggregated.", + "This invitation is not for your account": "This invitation is not for your account", + "This invitation was sent to {{email}}. You are currently logged in as {{userEmail}}.": "This invitation was sent to {{email}}. You are currently logged in as {{userEmail}}.", + "This will immediately revoke access for {{userName}}. Any active subscription will be cancelled, but no refund will be issued.": "This will immediately revoke access for {{userName}}. Any active subscription will be cancelled, but no refund will be issued.", + "This will revoke access for {{userName}}, cancel any active subscription, and issue a refund for the most recent payment. This action cannot be undone.": "This will revoke access for {{userName}}, cancel any active subscription, and issue a refund for the most recent payment. This action cannot be undone.", "This is group is invitation only": "This is group is invitation only", "This is still needed": "This is still needed", "This is the one group": "This is the one group", @@ -1324,6 +1414,7 @@ "Total {{tokenName}} to distribute": "Total {{tokenName}} to distribute", "Total {{tokenType}}": "Total {{tokenType}}", "Track": "Track", + "Track Access": "Track Access", "Track {{trackName}} Completed": "Track {{trackName}} Completed", "Track name": "Track name", "Track: {{trackName}}": "Track: {{trackName}}", @@ -1332,7 +1423,9 @@ "Type": "Type", "Type email addresses (multiples should be separated by either a comma or new line)": "Type email addresses (multiples should be separated by either a comma or new line)", "Type group name...": "Type group name...", + "Type member name...": "Type member name...", "Type persons name...": "Type persons name...", + "Type at least 2 characters to search": "Type at least 2 characters to search", "Type your answer here...": "Type your answer here...", "Type...": "Type...", "URL of organization": "URL of organization", @@ -1353,7 +1446,9 @@ "Unsubscribe": "Unsubscribe", "Upcoming": "Upcoming", "Upcoming Events": "Upcoming Events", + "Unnamed Offering": "Unnamed Offering", "Update": "Update", + "User selector, offering picker, group access, track access": "User selector, offering picker, group access, track access", "Update Account": "Update Account", "Update Funding Round": "Update Funding Round", "Update Track": "Update Track", @@ -1369,12 +1464,18 @@ "User Guide": "User Guide", "Valid start and end time required": "Valid start and end time required", "View": "View", + "View all {{context}}": "View all {{context}}", "View All Responses": "View All Responses", + "View and manage all content access grants for your group": "View and manage all content access grants for your group", + "View Records": "View Records", + "View subscribed users": "View subscribed users", "View Current Members": "View Current Members", "View Members": "View Members", "View Post": "View Post", + "View Receipt": "View Receipt", "View Profile": "View Profile", "View Round": "View Round", + "View in Stripe": "View in Stripe", "View all": "View all", "View and participate in public discussions, projects, events & more": "View and participate in public discussions, projects, events & more", "View details": "View details", @@ -1520,6 +1621,8 @@ "You joined after voting started. {{tokenType}} have already been allocated to participants who joined before voting began.": "You joined after voting started. {{tokenType}} have already been allocated to participants who joined before voting began.", "You left {{group_name}}": "You left {{group_name}}", "You may add parent groups if you are a Host of the group you wish to add, or if the group you wish to add has the Open access setting which allows any group to join it": "You may add parent groups if you are a Host of the group you wish to add, or if the group you wish to add has the Open access setting which allows any group to join it", + "You must accept all agreements to join": "You must accept all agreements to join", + "You must answer all questions and accept all agreements to join": "You must answer all questions and accept all agreements to join", "You must answer all the questions to join": "You must answer all the questions to join", "You need a title and at least one group to post": "You need a title and at least one group to post", "You need a title, a group and at least one option for a proposal": "You need a title, a group and at least one option for a proposal", @@ -1541,6 +1644,7 @@ "Your Profile": "Your Profile", "Your Settings": "Your Settings", "Your account and its details will be deleted": "Your account and its details will be deleted", + "Your purchases and subscriptions will appear here.": "Your purchases and subscriptions will appear here.", "Your account has no password set.": "Your account has no password set.", "Your account is registered, you're ready to accept contributions to projects.": "Your account is registered, you're ready to accept contributions to projects.", "Your affiliation was added": "Your affiliation was added", @@ -1814,6 +1918,7 @@ "widget-my-saved-posts": "Saved Posts", "widget-my-saved-searches": "Saved Searches", "widget-my-tracks": "Tracks", + "widget-my-transactions": "My Transactions", "widget-myself": "My Account", "widget-projects": "Projects", "widget-proposals": "Proposals", @@ -1908,5 +2013,40 @@ "{{personOne}}, {{personTwo}} and {{othersTotal}} other_plural": "{{personOne}}, {{personTwo}} and {{othersTotal}} others", "{{submissionDescriptor}} Criteria": "{{submissionDescriptor}} Criteria", "{{totalTopicsCached}} Total Topics": "{{totalTopicsCached}} Total Topics", - "~ Mixed ~": "~ Mixed ~" + "~ Mixed ~": "~ Mixed ~", + "1 Day": "1 Day", + "1 Day (Testing)": "1 Day (Testing)", + "1 Month": "1 Month", + "1 Season": "1 Season", + "1 Year": "1 Year", + "Choose a payment option below to gain access to this group": "Choose a payment option below to gain access to this group:", + "Duration": "Duration", + "Failed to start payment process: {{error}}": "Failed to start payment process: {{error}}", + "Grants access to the group": "Grants access to the group", + "Lifetime": "Lifetime", + "Loading payment options...": "Loading payment options...", + "No payment options are currently available. Please contact the group administrators.": "No payment options are currently available. Please contact the group administrators.", + "Price": "Price", + "Purchase Access": "Purchase Access", + "Roles": "Roles", + "This group requires a fee to join": "This group requires a fee to join", + "This group requires payment to join": "This group requires payment to join", + "Unable to process payment. Please contact support.": "Unable to process payment. Please contact support.", + "Checking access options...": "Checking access options...", + "Request access from a group admin to enroll": "Request access from a group admin to enroll", + "This track has controlled access": "This track has controlled access", + "Access to this track is granted by administrators. Please contact a group admin to request access.": "Access to this track is granted by administrators. Please contact a group admin to request access.", + "This track requires a fee to access": "This track requires a fee to access", + "Choose a payment option below to gain access to this track:": "Choose a payment option below to gain access to this track:", + "Access Controlled": "Access Controlled", + "Free Access": "Free Access", + "When enabled, users will need to purchase access or be granted access by an admin before they can enroll in this track.": "When enabled, users will need to purchase access or be granted access by an admin before they can enroll in this track.", + "You must create an offering that includes this track for users to purchase access.": "You must create an offering that includes this track for users to purchase access.", + "Go to Paid Content Settings": "Go to Paid Content Settings", + "You need to be granted access to view the actions in this track.": "You need to be granted access to view the actions in this track.", + "Assign a role to invitees (optional):": "Assign a role to invitees (optional):", + "No special role": "No special role", + "Coordinator": "Coordinator", + "Host": "Host", + "When you join, you will receive the {{roleName}} role": "When you join, you will receive the {{roleName}} role" } diff --git a/apps/web/public/locales/es.json b/apps/web/public/locales/es.json index ef65608414..44e156d513 100644 --- a/apps/web/public/locales/es.json +++ b/apps/web/public/locales/es.json @@ -49,10 +49,13 @@ "Access to your phone number.": "Acceso a tu número de teléfono", "Access to your physical address.": "Acceso a tu dirección física", "Access your profile, including your name and image.": "Acceso a tu perfil, incluyendo tu nombre e imagen", + "Access Type": "Tipo de acceso", "Account": "Cuenta", "Account Settings": "Configuración de cuenta", "Account already exists": "la cuenta ya existe", "Active": "Activo", + "Active Members": "Miembros Activos", + "Active Subscribers": "Suscriptores Activos", "Activity": "Actividad", "Add": "Agregar", "Add Affiliation": "Agregar afiliación", @@ -119,6 +122,7 @@ "Add {{actionDescriptor}}": "Agregar {{actionDescriptor}}", "Add {{something}} to Menu": "Agregar {{something}} al menú", "Add {{submissionDescriptor}}": "Agregar {{SubmissionDescriptor}}", + "Admin Grant": "Concesión de administrador", "Admin Only": "Sólo administrador", "Administrators will always see exact location": "Los administradores siempre verán la ubicación exacta", "Advice": "Consejo", @@ -131,7 +135,12 @@ "All Groups": "Todos los grupos", "All Hylo group events you RSVP to": "Todos los eventos de grupo de Hylo a los que te has inscrito", "All My Groups": "Todos mis grupos", + "All Offerings": "Todas las ofertas", "All Posts": "Todas las publicaciones", + "All Roles": "Todos los roles", + "All Status": "Todos los estados", + "All Tracks": "Todos los tracks", + "All Types": "Todos los tipos", "All Posts Marked Public": "Todas las publicaciones públicas", "All Topics": "Todos los temas", "All Views": "Todas las vistas", @@ -255,6 +264,8 @@ "Can you go?": "¿Puedes ir?", "Cancel": "Cancelar", "Cancel Invite": "Cancelar invitación", + "Choose a track...": "Elige una ruta...", + "Choose an offering...": "Elige una oferta...", "Cancel Request": "Cancelar solicitud", "Canceled": "Cancelado", "Canceled Join Requests": "Solicitudes de unión canceladas", @@ -324,6 +335,8 @@ "Contact {{group.name}} to learn about their practices": "Contacta a {{group.name}} para conocer sus prácticas", "Container": "Container", "Container Widget": "Container Widget", + "Content Access": "Acceso a contenido", + "Content Access Records": "Registros de acceso a contenido", "Continue": "Continuar", "Continue with Google": "Continuar con Google", "Contribute": "Contribuir", @@ -332,6 +345,7 @@ "Contributions so far: {{totalContributions}}": "Contribuciones hasta ahora: {{totalContributions}}", "Cookie Preferences": "Preferencias de galletas", "Cooperatives": "Cooperativas", + "Could not determine group from invitation": "No se pudo determinar el grupo de la invitación", "Copied!": "¡Copiado!", "Copy": "Copiar", "Copy Invite Link": "Copiar enlace de invitación", @@ -459,6 +473,7 @@ "Edit {{submissionDescriptor}}": "Editar {{submissionDescriptor}}", "Editing Group Menu": "Menú del grupo de edición", "Editing coming soon": "Editar próximamente", + "Either your membership has lapsed or the group stewards have added a paywall to the group.": "Tu membresía ha expirado o los administradores del grupo han añadido un muro de pago al grupo.", "Email": "Correo electrónico", "Email address is not in a valid format": "La dirección de correo electrónico no tiene un formato válido", "Email address not found": "Dirección de correo electrónico no encontrado", @@ -487,6 +502,8 @@ "Equipment sharing": "Compartir equipamiento", "Error": "Error", "Error leaving {{group_name}}": "Error dejando {{group_name}}", + "Error loading transactions": "Error al cargar transacciones", + "Error loading subscriber data": "Error al cargar datos de suscriptores", "Essential cookies": "Galletas esenciales", "Event collaboration": "Colaboración en eventos", "Event ended": "Evento finalizado", @@ -498,7 +515,10 @@ "Example: \"I will not troll or be intentionally divisive\"": "Ejemplo: \"No trolearé ni seré intencionalmente divisivo\"", "Example: \"I will only post content relevant to this group\"": "Ejemplo: \"Solo publicaré contenido relevante para este grupo\"", "Expire": "Expira", + "Expired": "Expirado", + "Expires": "Expira", "Explanation for Flagging": "Explicación para marcar", + "Failed to grant access": "Error al conceder acceso", "Explore": "Explorar", "Explore Groups": "Explorar grupos", "Export Data": "Exportar datos", @@ -551,7 +571,15 @@ "Go to tracks settings": "Vaya a la configuración de las pistas", "Going": "Yendo", "Gradient of Agreement": "Gradiente de Acuerdo", + "Granted": "Concedido", + "Granted by": "Concedido por", + "Grant Access": "Conceder Acceso", + "Grant access form will be implemented here": "El formulario de concesión de acceso se implementará aquí", + "Grant users access to group content, tracks, or offerings": "Conceder a usuarios acceso a contenido del grupo, rutas u ofertas", + "Granting...": "Concediendo...", + "Granting group access will add the selected user as a member of this group.": "Conceder acceso al grupo agregará al usuario seleccionado como miembro de este grupo.", "Group": "Grupo", + "Group Access": "Acceso al Grupo", "Group Access Questions": "Preguntas de acceso grupal", "Group Agreements": "Acuerdos del Grupo", "Group Agreements broken": "Acuerdos de grupo rotos", @@ -639,6 +667,7 @@ "Interested": "Interesado", "Invalid code, please try again": "Código no válido, por favor vuelve a intentarlo", "Invalid email address": "Dirección de correo electrónico no válida", + "Invalid invitation": "Invitación no válida", "Invalid url. Please enter the full url for your {{network}} page.": "URL no válida. Ingrese la URL completa para su página de {{network}}.", "Invitations": "Invitaciones", "Invitations to Join New Groups": "Invitaciones para unirse a nuevos grupos", @@ -659,6 +688,7 @@ "Is this resource still available?": "¿Sigue disponible este recurso?", "Join": "Unirse", "Join Hylo": "Únete a Hylo", + "Joined": "Se unió", "Join Parent Groups": "Unirse a Grupos Padres", "Join Project": "Únete al proyecto", "Join Question Responses": "Unirse a las respuestas de las preguntas", @@ -677,6 +707,8 @@ "Label": "Etiqueta", "Language": "Idioma", "Language Settings": "Configuración de idioma", + "Lapsed": "Vencido", + "Lapsed Members": "Miembros Vencidos", "Large Grid": "Cuadrícula grande", "Latest activity": "Última actividad", "Leave": "Salir", @@ -718,6 +750,10 @@ "Make your group discoverable in the Murmurations network": "Haz que tu grupo sea visible en la red Murmurations", "Manage": "Administrar", "Manage Notifications": "Gestionar notificaciones", + "Manage in Stripe": "Administrar en Stripe", + "Manage Subscription": "Administrar Suscripción", + "Manage your purchases and subscriptions below.": "Administra tus compras y suscripciones a continuación.", + "Manage content access settings for your group offerings.": "Administrar la configuración de acceso al contenido para las ofertas de tu grupo.", "Management Techniques: ": "Técnicas de gestión:", "Manual": "Manual", "Manually": "A mano", @@ -756,6 +792,8 @@ "Moderator": "Moderador", "Moderators": "Moderadores", "Month": "Mes", + "month": "mes", + "Monthly Revenue": "Ingresos Mensuales", "Multiple Choice": "Opción multiple", "Multiple people are typing...": "Varias personas están escribiendo...", "Multiple votes allowed": "Se permiten varios votos", @@ -767,6 +805,7 @@ "My Invites": "Mis invitaciones", "My Posts": "Mis Publicaciones", "My Skills & Interests": "Mis habilidades e intereses", + "My Transactions": "Mis Transacciones", "NO MORE RECENT ACTIVITY": "NO MAS ACTIVIDAD RECIENTE", "NOT": "NO ES", "NOTIFICATIONS": "NOTIFICACIONES", @@ -810,8 +849,13 @@ "No Posts": "Ningún publicaciones", "No active invitations to join new groups": "No hay invitaciones activas para unirte a nuevos grupos", "No chat topics found": "No se encontraron temas de chat", + "No content access records found": "No se encontraron registros de acceso a contenido", "No emoji": "Sin emoji", "No events": "No eventos", + "No offerings available": "No hay ofertas disponibles", + "No results found": "No se encontraron resultados", + "No transactions yet": "Aún no hay transacciones", + "No users found": "No se encontraron usuarios", "No funding rounds match": "No hay rondas de financiación que coincidan", "No groups are members of {{group.name}} yet": "Ningún grupos son miembros de {{group.name}} todavía", "No groups match": "No se encontraron grupos", @@ -833,6 +877,8 @@ "No result": "Sin resultados", "No results for this search": "No hay resultados para esta búsqueda", "No saved searches. You can set them up in the map": "No hay búsquedas guardadas. Puedes configurarlas en el mapa", + "No active subscribers yet": "Aún no hay suscriptores activos", + "No lapsed members": "No hay miembros vencidos", "No submissions yet": "No hay envíos aún", "No thanks": "No, gracias", "No tracks match": "No hay partidos de pistas", @@ -863,12 +909,15 @@ "ON": "ENCENDIDO", "Off": "Apagado", "Offensive": "Ofensivo", + "Offering": "Oferta", + "Offerings": "Ofertas", "Offer": "Oferta", "Offers": "Ofertas", "Oh no, something went wrong! Check your internet connection and try again": "¡Oh no, algo salió mal! \nComprueba tu conexión a Internet e inténtalo de nuevo", "Ok": "De acuerdo", "On": "Encendido", "One more step!": "¡Un paso más!", + "One-time purchase": "Compra única", "Only active": "Solo activo", "Only members of this group can see posts": "Solo los miembros de este grupo pueden ver las publicaciones.", "Only members of this group or direct child groups can see it": "Solo los miembros de este grupo o grupos secundarios directos pueden verlo", @@ -910,11 +959,15 @@ "Override Name": "Anular nombre", "Overview": "Descripción", "PERSONAL": "PERSONAL", + "Page": "Página", + "Paid Content": "Contenido pago", "Parent Groups": "Grupos principales", "Participants": "Participantes", "Password": "Contraseña", "Password (at least 9 characters)": "Contraseña (al menos 9 caracteres)", "Passwords don't match": "Las contraseñas no coinciden", + "Payment Type": "Tipo de Pago", + "Paywall enabled": "Muro de pago habilitado", "Passwords must be at least 9 characters long": "Las contraseñas deben tener al menos 9 caracteres", "Passwords must be at least 9 characters long, and should be a mix of lower and upper case letters, numbers and symbols.": "Las contraseñas deben tener al menos 9 caracteres y deben ser una combinación de letras mayúsculas y minúsculas, números y símbolos.", "Past Events": "Eventos pasados", @@ -935,6 +988,8 @@ "Pending requests to join other groups": "Solicitudes pendientes para unirse a otros grupos", "People": "Gente", "People can apply to join this group and must be approved": "Las personas pueden solicitar unirse a este grupo y deben ser aprobadas", + "Please select a user and an access type": "Por favor selecciona un usuario y un tipo de acceso", + "Please select at least one user and an access type": "Por favor selecciona al menos un usuario y un tipo de acceso", "People want to join your group!": "¡La gente quiere unirse a tu grupo!", "Phase Timeline": "Línea de tiempo de la fase", "Pin": "Fijar", @@ -955,6 +1010,10 @@ "Please enter your twitter name.": "Por favor introduce tu nombre de Twitter.", "Please provide either a `token` query string parameter or `accessCode` route param": "Proporcione un parámetro de cadena de consulta `token` o un parámetro de ruta `accessCode`", "Please provide either a token query string parameter or accessCode route param": "Proporcione un parámetro de cadena de consulta de token o ruta de accesscode param", + "Please answer the following": "Por favor responda lo siguiente", + "Please log in with the correct account or request a new invitation.": "Por favor inicie sesión con la cuenta correcta o solicite una nueva invitación.", + "Please review and accept the following agreements": "Por favor revise y acepte los siguientes acuerdos", + "Please review and accept the following agreements to join": "Por favor revise y acepte los siguientes acuerdos para unirse", "Plural word used to describe group Stewards": "Palabra en plural utilizada para describir a los administradores de grupo", "Poll, Single Vote": "Encuesta, voto único", "Popular": "Popular", @@ -985,11 +1044,13 @@ "Project Members": "Miembros del proyecto", "Projects": "Proyectos", "Projects help you and your group accomplish shared goals.": "Los proyectos te ayudan a ti y a tu grupo a lograr objetivos compartidos.", + "Processing...": "Procesando...", "Proposal options": "Opciones de propuesta", "Proposal template": "Plantilla de propuesta", "Proposals": "Propuestas", "Proposals require at least one option": "Las propuestas requieren al menos una opción.", "Protected": "Protegido", + "Purchased": "Comprado", "Public": "Público", "Public Group Link": "Enlace de Grupo Público", "Public Groups": "Grupos Públicos", @@ -1040,6 +1101,10 @@ "Remove from Menu": "Quitar del menú", "Remove from Round": "Quitar de la ronda", "Remove from group as well": "Eliminar del grupo también", + "Renews": "Se renueva", + "Refunded": "Reembolsado", + "Revoked": "Revocado", + "Role Access": "Acceso a Rol", "Remove post from group": "Eliminar publicación del grupo", "Remove post?": "¿Quitar publicación?", "Reopen {{submissionDescriptorPlural}}": "Reabrir {{submissionDescriptorPlural}}", @@ -1080,6 +1145,12 @@ "Restricted group": "Grupo restringido", "Return to All Groups": "Volver a Todos los grupos", "Revert": "Revertir", + "Revoke Access": "Revocar Acceso", + "Revoked": "Revocado", + "Revoking...": "Revocando...", + "Refund": "Reembolsar", + "Refund Purchase": "Reembolsar Compra", + "Role": "Rol", "Roles & Badges": "Roles y insignias", "Roles in {{group}}": "Roles en {{grupo}}", "Round Complete!": "¡Ronda Completa!", @@ -1109,6 +1180,7 @@ "Search currencies or type a custom name": "Buscar monedas o escribir un nombre personalizado", "Search for a location...": "Buscar una ubicación...", "Search for badges or roles": "Buscar insignias o roles", + "Search by member name": "Buscar por nombre de miembro", "Search for people, posts and comments": "Buscar personas, publicaciones y comentarios", "Search for people...": "Búsqueda de personas", "Search for posts": "Buscar publicaciones", @@ -1166,9 +1238,18 @@ "Set the start and end dates for the submissions and voting phases. If no dates are set in advance, phases will start and stop by manually managing the round.": "Establecer las fechas de inicio y finalización de las fases de presentación y votación. \nSi no se fijan fechas con antelación, las fases comenzarán y finalizarán gestionando manualmente la ronda.", "Set track banner": "Establecer banner de la ruta", "Set your password here.": "Establece tu contraseña aquí.", + "Select a user and choose what access to grant them.": "Selecciona un usuario y elige qué acceso concederle.", + "Select Offering": "Seleccionar Oferta", + "Select Track": "Seleccionar Ruta", + "Select User": "Seleccionar Usuario", + "Search...": "Buscar...", + "Search for a user...": "Buscar un usuario...", + "Searching...": "Buscando...", "Settings": "Ajustes", "Setup": "Configuración", "Share a Join Link": "Compartir un enlace para unirse", + "Show Active": "Mostrar Activos", + "Show Lapsed": "Mostrar Vencidos", "Share about who you are, your skills & interests": "Comparte quién eres, tus habilidades e intereses", "Show Answers": "Mostrar respuestas", "Show Less": "Muestra menos", @@ -1181,7 +1262,9 @@ "Show posts from child groups": "Mostrar publicaciones de subgrupos", "Show posts from child groups you are a member of": "Mostrar publicaciones de los subgrupos que eres miembro", "Show this welcome page to new members when they first land in the group. If this is turned off then they will go directly to your home view.": "Muestra esta página de bienvenida a nuevos miembros cuando llegan al grupo. Si está apagado, irán directamente a tu vista de inicio.", + "Showing {{count}} of {{total}} records": "Mostrando {{count}} de {{total}} registros", "Sign Up": "Regístrate", + "{{count}} user(s) selected": "{{count}} usuario(s) seleccionado(s)", "Sign in": "Iniciar sesión", "Sign in to Hylo": "Iniciar sesión en Hylo", "Sign up": "Regístrate", @@ -1208,7 +1291,9 @@ "Starts {{date}}": "Comienza el {{fecha}}", "Starts: {{from}}": "Comienza: {{desde}}", "Stay connected, organized, and engaged with your group.": "Manténte conectado, organizado y comprometido con tu grupo.", + "Status": "Estado", "Stream": "Flujo", + "Stripe Purchase": "Compra de Stripe", "Subgroups": "Subgrupos", "Submission": "Envío", "Submission Criteria": "Criterios de envío", @@ -1236,6 +1321,7 @@ "Submit": "Enviar", "Submit Attachments and Complete": "Enviar archivos adjuntos y completar", "Subscribe": "Suscribir", + "Subscription": "Suscripción", "Suggest a proposal for others to vote on": "Sugerir una propuesta para que otros voten", "Suggested": "Sugerido", "Support (Intercom)": "Soporte (Intercomunicador)", @@ -1270,6 +1356,8 @@ "These settings apply to all groups. Toggling related group settings will turn off these default settings.": "Esta configuración se aplica a todos los grupos. Cambiar la configuración relacionada con el grupo desactivará esta configuración predeterminada.", "These {{childGroups.length}} groups are members of {{group.name}}": "Estos {{childGroups.length}} grupos son miembros de {{group.name}}", "They will still have the opportunity to answer any join questions and agree to agreements before they enter the group.": "Tendrán la oportunidad de responder a cualquier pregunta de unión y aceptar acuerdos antes de entrar al grupo.", + "This will immediately revoke access for {{userName}}. Any active subscription will be cancelled, but no refund will be issued.": "Esto revocará inmediatamente el acceso para {{userName}}. Cualquier suscripción activa será cancelada, pero no se emitirá ningún reembolso.", + "This will revoke access for {{userName}}, cancel any active subscription, and issue a refund for the most recent payment. This action cannot be undone.": "Esto revocará el acceso para {{userName}}, cancelará cualquier suscripción activa y emitirá un reembolso por el pago más reciente. Esta acción no se puede deshacer.", "This URL already exists. Try another.": "Esta URL ya existe. Intenta otra.", "This action is": "Esta acción", "This action is reversible, just log back in": "Esta acción es reversible, simplemente vuelve a iniciar sesión", @@ -1281,6 +1369,8 @@ "This group is invitation only": "Este grupo es solo por invitación", "This helps us provide better customer support and track bug reports. Your conversations are stored securely.": "Esto nos ayuda a brindar una mejor atención al cliente y realizar un seguimiento de los informes de errores. \nTus conversaciones se almacenan de forma segura.", "This helps us understand how people use Hylo so we can improve the platform. Your data is anonymized and aggregated.": "Esto nos ayuda a comprender cómo la gente usa Hylo para que podamos mejorar la plataforma. \nSus datos son anonimizados y agregados.", + "This invitation is not for your account": "Esta invitación no es para tu cuenta", + "This invitation was sent to {{email}}. You are currently logged in as {{userEmail}}.": "Esta invitación fue enviada a {{email}}. Actualmente has iniciado sesión como {{userEmail}}.", "This is group is invitation only": "Este es un grupo solo por invitación", "This is still needed": "esto todavía es necesario", "This is the one group": "Este es el único grupo", @@ -1326,6 +1416,7 @@ "Total {{tokenName}} to distribute": "Total {{tokenName}} a distribuir", "Total {{tokenType}}": "Total de {{tokenType}}", "Track": "Ruta", + "Track Access": "Acceso a Ruta", "Track {{trackName}} Completed": "Ruta {{trackName}} completada", "Track name": "Nombre de la ruta", "Track: {{trackName}}": "Ruta: {{trackName}}", @@ -1334,7 +1425,9 @@ "Type": "Tipo", "Type email addresses (multiples should be separated by either a comma or new line)": "Escribe las direcciones de correo electrónico (si son varias, deben estar separadas por una coma o una nueva línea)", "Type group name...": "Escribe el nombre del grupo...", + "Type member name...": "Escribe el nombre del miembro...", "Type persons name...": "Escribe su nombre...", + "Type at least 2 characters to search": "Escribe al menos 2 caracteres para buscar", "Type your answer here...": "Escribe su respuesta aquí...", "Type...": "Escribe...", "URL of organization": "URL de la organización", @@ -1355,7 +1448,9 @@ "Unsubscribe": "Darse de baja", "Upcoming": "Próximos", "Upcoming Events": "Próximos Eventos", + "Unnamed Offering": "Oferta sin nombre", "Update": "Actualizar", + "User selector, offering picker, group access, track access": "Selector de usuarios, selector de ofertas, acceso a grupo, acceso a ruta", "Update Account": "Actualizar cuenta", "Update Funding Round": "Actualizar ronda de financiación", "Update Track": "Actualizar ruta", @@ -1371,12 +1466,18 @@ "User Guide": "Guía del Usuario", "Valid start and end time required": "Hora de inicio y fin válidos requeridos", "View": "Vista", + "View all {{context}}": "Ver todo {{context}}", "View All Responses": "Ver todas las respuestas", + "View and manage all content access grants for your group": "Ver y administrar todas las concesiones de acceso a contenido de tu grupo", + "View Records": "Ver Registros", + "View subscribed users": "Ver usuarios suscritos", "View Current Members": "Ver miembros actuales", "View Members": "Ver Miembros", "View Post": "Ver publicación", + "View Receipt": "Ver Recibo", "View Profile": "Ver perfil", "View Round": "Ver ronda", + "View in Stripe": "Ver en Stripe", "View all": "Ver todo", "View and participate in public discussions, projects, events & more": "Ver y participar en discusiones públicas, proyectos, eventos", "View details": "Ver detalles", @@ -1524,6 +1625,8 @@ "You joined after voting started. {{tokenType}} have already been allocated to participants who joined before voting began.": "Te uniste después de que comenzó la votación. \n{{tokenType}} ya se han asignado a los participantes que se unieron antes de que comenzara la votación.", "You left {{group_name}}": "Dejaste {{group_name}}", "You may add parent groups if you are a Host of the group you wish to add, or if the group you wish to add has the Open access setting which allows any group to join it": "Puede agregar grupos principales si es un anfitrión del grupo que desea agregar, o si el grupo que desea agregar tiene la configuración de acceso abierto que permite a cualquier grupo unirse a él", + "You must accept all agreements to join": "Debes aceptar todos los acuerdos para unirte", + "You must answer all questions and accept all agreements to join": "Debes responder todas las preguntas y aceptar todos los acuerdos para unirte", "You must answer all the questions to join": "Debes responder todas las preguntas para unirte", "You need a title and at least one group to post": "Necesitas un título y al menos un grupo para publicar", "You need a title, a group and at least one option for a proposal": "Necesitas un título, un grupo y al menos una opción para una propuesta.", @@ -1545,6 +1648,7 @@ "Your Profile": "Tu perfil", "Your Settings": "Tu configuración", "Your account and its details will be deleted": "Tu cuenta y sus detalles serán eliminados", + "Your purchases and subscriptions will appear here.": "Tus compras y suscripciones aparecerán aquí.", "Your account has no password set.": "Tu cuenta no tiene contraseña establecida", "Your account is registered, you're ready to accept contributions to projects.": "Your account is registered, you're ready to accept contributions to projects.", "Your affiliation was added": "Tu afiliación fue agregada", @@ -1819,6 +1923,7 @@ "widget-my-saved-posts": "Publicaciones Guardadas", "widget-my-saved-searches": "Búsquedas Guardadas", "widget-my-tracks": "Pistas", + "widget-my-transactions": "Mis Transacciones", "widget-myself": "Mi Cuenta", "widget-projects": "Proyectos", "widget-proposals": "Propuestas", @@ -1914,5 +2019,41 @@ "{{submissionDescriptor}} Criteria": "{{submissionDescriptor}} Criterios", "{{totalTopicsCached}} Total Topics": "{{totalTopicsCached}} Total de temas", "{{unseenCount}} unread notifications": "{{unseenCount}} notificaciones no leídas", - "~ Mixed ~": "~ Mezclado ~" + "~ Mixed ~": "~ Mezclado ~", + "1 Day": "1 Día", + "1 Day (Testing)": "1 Día (Pruebas)", + "1 Month": "1 Mes", + "1 Season": "1 Temporada", + "1 Year": "1 Año", + "Choose a payment option below to gain access to this group": "Elige una opción de pago a continuación para acceder a este grupo:", + "Duration": "Duración", + "Failed to start payment process: {{error}}": "Error al iniciar el proceso de pago: {{error}}", + "Grants access to the group": "Otorga acceso al grupo", + "Lifetime": "De por vida", + "Loading payment options...": "Cargando opciones de pago...", + "No payment options are currently available. Please contact the group administrators.": "No hay opciones de pago disponibles actualmente. Por favor, contacta a los administradores del grupo.", + "Price": "Precio", + "Processing...": "Procesando...", + "Purchase Access": "Comprar Acceso", + "Roles": "Roles", + "This group requires a fee to join": "Este grupo requiere una cuota para unirse", + "This group requires payment to join": "Este grupo requiere un pago para unirse", + "Unable to process payment. Please contact support.": "No se puede procesar el pago. Por favor, contacta al soporte.", + "Checking access options...": "Verificando opciones de acceso...", + "Request access from a group admin to enroll": "Solicita acceso a un administrador del grupo para inscribirte", + "This track has controlled access": "Este track tiene acceso controlado", + "Access to this track is granted by administrators. Please contact a group admin to request access.": "El acceso a este track es otorgado por administradores. Por favor, contacta a un administrador del grupo para solicitar acceso.", + "This track requires a fee to access": "Este track requiere una cuota para acceder", + "Choose a payment option below to gain access to this track:": "Elige una opción de pago a continuación para obtener acceso a este track:", + "Access Controlled": "Acceso Controlado", + "Free Access": "Acceso Libre", + "When enabled, users will need to purchase access or be granted access by an admin before they can enroll in this track.": "Cuando está habilitado, los usuarios deberán comprar acceso o recibir acceso de un administrador antes de poder inscribirse en este track.", + "You must create an offering that includes this track for users to purchase access.": "Debes crear una oferta que incluya este track para que los usuarios puedan comprar acceso.", + "Go to Paid Content Settings": "Ir a Configuración de Contenido de Pago", + "You need to be granted access to view the actions in this track.": "Necesitas que se te otorgue acceso para ver las acciones en este track.", + "Assign a role to invitees (optional):": "Asignar un rol a los invitados (opcional):", + "No special role": "Sin rol especial", + "Coordinator": "Coordinador", + "Host": "Anfitrión", + "When you join, you will receive the {{roleName}} role": "Cuando te unas, recibirás el rol de {{roleName}}" } diff --git a/apps/web/src/components/GroupCard/GroupCard.js b/apps/web/src/components/GroupCard/GroupCard.js index 97941d2586..a20f8ad0ff 100644 --- a/apps/web/src/components/GroupCard/GroupCard.js +++ b/apps/web/src/components/GroupCard/GroupCard.js @@ -15,7 +15,7 @@ import { import { bgImageStyle, cn } from 'util/index' import { groupUrl, groupDetailUrl } from '@hylo/navigation' -import { UserRoundCheck } from 'lucide-react' +import { UserRoundCheck, DollarSign } from 'lucide-react' import Icon from 'components/Icon' import RoundImage from 'components/RoundImage' @@ -70,6 +70,14 @@ export default function GroupCard ({ group }) {
{t(accessibilityString(group.accessibility))} - {t(accessibilityDescription(group.accessibility))}
+ {group.paywall && ( +
+ +
+
{t('Paywall')} - {t('This group requires payment to join')}
+
+
+ )} { group.memberStatus === 'member' ?
{t('Member')}
diff --git a/apps/web/src/components/ItemSelector/ItemSelector.js b/apps/web/src/components/ItemSelector/ItemSelector.js new file mode 100644 index 0000000000..fb9f8b271b --- /dev/null +++ b/apps/web/src/components/ItemSelector/ItemSelector.js @@ -0,0 +1,260 @@ +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' +import { useDispatch } from 'react-redux' +import { useTranslation } from 'react-i18next' +import { debounce } from 'lodash/fp' +import { Search, X } from 'lucide-react' +import RoundImage from 'components/RoundImage' +import Loading from 'components/Loading' + +/** + * ItemSelector Component + * + * A flexible selector component for searching and selecting items (users, groups, etc). + * Inspired by the mobile ItemSelector but adapted for web with Tailwind styling. + * + * @param {Object} props + * @param {Array} props.items - Pre-loaded items to display (optional) + * @param {Array} props.defaultItems - Items to show when no search term + * @param {Object} props.selectedItem - Currently selected item (for single select) + * @param {Function} props.onSelect - Callback when an item is selected + * @param {Function} props.fetchItems - Redux action creator for fetching items + * @param {Function} props.itemsSelector - Redux selector to get items from state + * @param {String} props.searchPlaceholder - Placeholder text for search input + * @param {Boolean} props.loading - External loading state + * @param {String} props.emptyMessage - Message to show when no items found + * @param {Function} props.renderItem - Custom item renderer (optional) + * @param {Function} props.filterItems - Function to filter items (optional) + */ +export default function ItemSelector ({ + items: providedItems, + defaultItems = [], + selectedItem = null, + onSelect, + fetchItems, + itemsSelector, + searchPlaceholder, + loading: externalLoading = false, + emptyMessage, + renderItem: CustomItemRenderer, + filterItems +}) { + const { t } = useTranslation() + const dispatch = useDispatch() + const inputRef = useRef(null) + + const [searchTerm, setSearchTerm] = useState('') + const [internalLoading, setInternalLoading] = useState(false) + const [fetchedItems, setFetchedItems] = useState([]) + const [showDropdown, setShowDropdown] = useState(false) + const [selectedIndex, setSelectedIndex] = useState(-1) + + const loading = externalLoading || internalLoading + + // Debounced fetch function + const debouncedFetch = useMemo(() => + debounce(300, async (term) => { + if (fetchItems && term && term.length >= 2) { + setInternalLoading(true) + try { + const result = await dispatch(fetchItems({ autocomplete: term })) + // Extract items from the result based on the expected structure + const items = result?.payload?.data?.people?.items || [] + setFetchedItems(items) + } catch (error) { + console.error('Error fetching items:', error) + setFetchedItems([]) + } finally { + setInternalLoading(false) + } + } else { + setFetchedItems([]) + } + }), + [dispatch, fetchItems] + ) + + // Trigger search when search term changes + useEffect(() => { + if (searchTerm.length >= 2) { + debouncedFetch(searchTerm) + setShowDropdown(true) + } else { + setFetchedItems([]) + if (searchTerm.length === 0) { + setShowDropdown(false) + } + } + return () => debouncedFetch.cancel && debouncedFetch.cancel() + }, [searchTerm, debouncedFetch]) + + // Determine which items to display + const displayItems = useMemo(() => { + let items = providedItems || fetchedItems || defaultItems + + // Apply custom filter if provided + if (filterItems && typeof filterItems === 'function') { + items = filterItems(items, searchTerm) + } + + // Filter out already selected item + if (selectedItem) { + items = items.filter(item => item.id !== selectedItem.id) + } + + return items + }, [providedItems, fetchedItems, defaultItems, filterItems, searchTerm, selectedItem]) + + /** + * Handles item selection + */ + const handleSelect = useCallback((item) => { + if (onSelect) { + onSelect(item) + } + setSearchTerm('') + setShowDropdown(false) + setSelectedIndex(-1) + }, [onSelect]) + + /** + * Clears the current selection + */ + const handleClear = useCallback(() => { + if (onSelect) { + onSelect(null) + } + setSearchTerm('') + inputRef.current?.focus() + }, [onSelect]) + + /** + * Handles keyboard navigation + */ + const handleKeyDown = useCallback((e) => { + if (!showDropdown || displayItems.length === 0) return + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setSelectedIndex(prev => Math.min(prev + 1, displayItems.length - 1)) + break + case 'ArrowUp': + e.preventDefault() + setSelectedIndex(prev => Math.max(prev - 1, -1)) + break + case 'Enter': + e.preventDefault() + if (selectedIndex >= 0 && displayItems[selectedIndex]) { + handleSelect(displayItems[selectedIndex]) + } + break + case 'Escape': + setShowDropdown(false) + setSelectedIndex(-1) + break + default: + break + } + }, [showDropdown, displayItems, selectedIndex, handleSelect]) + + /** + * Default item renderer + */ + const DefaultItemRenderer = useCallback(({ item, isSelected, onSelect: handleItemSelect }) => ( + + ), []) + + const ItemRenderer = CustomItemRenderer || DefaultItemRenderer + + return ( +
+ {/* Selected Item Display */} + {selectedItem + ? ( +
+ {selectedItem.avatarUrl && ( + + )} + {selectedItem.name} + +
+ ) + : ( + <> + {/* Search Input */} +
+ + setSearchTerm(e.target.value)} + onKeyDown={handleKeyDown} + onFocus={() => searchTerm.length >= 2 && setShowDropdown(true)} + placeholder={searchPlaceholder || t('Search...')} + className='w-full pl-10 pr-4 py-2 bg-input border border-foreground/20 rounded-md text-foreground placeholder:text-foreground/50 focus:border-focus focus:outline-none' + /> + {loading && ( +
+ +
+ )} +
+ + {/* Dropdown */} + {showDropdown && ( +
+ {loading && displayItems.length === 0 && ( +
+ {t('Searching...')} +
+ )} + {!loading && displayItems.length === 0 && searchTerm.length >= 2 && ( +
+ {emptyMessage || t('No results found')} +
+ )} + {!loading && searchTerm.length < 2 && ( +
+ {t('Type at least 2 characters to search')} +
+ )} + {displayItems.map((item, index) => ( + + ))} +
+ )} + + )} +
+ ) +} diff --git a/apps/web/src/components/ItemSelector/index.js b/apps/web/src/components/ItemSelector/index.js new file mode 100644 index 0000000000..9d91472140 --- /dev/null +++ b/apps/web/src/components/ItemSelector/index.js @@ -0,0 +1 @@ +export { default } from './ItemSelector' diff --git a/apps/web/src/components/PostEditor/PostEditor.js b/apps/web/src/components/PostEditor/PostEditor.js index 03f48c740b..f3eab8a48f 100644 --- a/apps/web/src/components/PostEditor/PostEditor.js +++ b/apps/web/src/components/PostEditor/PostEditor.js @@ -231,7 +231,18 @@ function PostEditor ({ const [showLocation, setShowLocation] = useState(POST_TYPES_SHOW_LOCATION_BY_DEFAULT.includes(initialPost.type) || selectedLocation) const groupOptions = useMemo(() => { - return currentUser ? currentUser.memberships.toModelArray().map((m) => m.group).sort((a, b) => a.name.localeCompare(b.name)) : [] + if (!currentUser) return [] + + return currentUser.memberships.toModelArray() + .map((m) => m.group) + .filter((g) => { + // Filter out paywalled groups where user doesn't have access + if (g?.paywall && g?.canAccess === false) { + return false + } + return true + }) + .sort((a, b) => a.name.localeCompare(b.name)) }, [currentUser?.memberships]) const myAdminGroups = useSelector(state => getMyAdminGroups(state, groupOptions)) diff --git a/apps/web/src/components/SettingsControl/SettingsControl.js b/apps/web/src/components/SettingsControl/SettingsControl.js index cc03fb95ff..055939f7c9 100644 --- a/apps/web/src/components/SettingsControl/SettingsControl.js +++ b/apps/web/src/components/SettingsControl/SettingsControl.js @@ -10,7 +10,8 @@ export default function SettingsControl (props) { let control if (renderControl) { - control = renderControl(props) + // Pass only the props that should go to the control element, excluding renderControl and helpText + control = renderControl({ id, value, onChange, ...otherProps }) } else { switch (type) { case 'textarea': diff --git a/apps/web/src/components/TrackCard/TrackCard.jsx b/apps/web/src/components/TrackCard/TrackCard.jsx index 68329c3cb3..d74d79ecb4 100644 --- a/apps/web/src/components/TrackCard/TrackCard.jsx +++ b/apps/web/src/components/TrackCard/TrackCard.jsx @@ -1,4 +1,4 @@ -import { CopyPlus, Eye, EyeOff, Pencil, Users, UserCheck } from 'lucide-react' +import { CopyPlus, Eye, EyeOff, Pencil, Users, UserCheck, DollarSign } from 'lucide-react' import React, { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' @@ -40,7 +40,7 @@ function TrackCard ({ track }) { } }, [track.id]) - const { actionDescriptorPlural, didComplete, isEnrolled, name, numActions, numPeopleCompleted, numPeopleEnrolled, publishedAt } = track + const { actionDescriptorPlural, didComplete, isEnrolled, name, numActions, numPeopleCompleted, numPeopleEnrolled, publishedAt, accessControlled } = track const handleButtonClick = (event) => { event.preventDefault() // Prevents the click event from bubbling up to the Link @@ -52,6 +52,15 @@ function TrackCard ({ track }) {

{name}

+ {accessControlled && ( + <> + + + )} {numActions} {actionDescriptorPlural} {canEdit && } diff --git a/apps/web/src/components/TrackEditor/TrackEditor.js b/apps/web/src/components/TrackEditor/TrackEditor.js index 33668f4ce8..9420e16330 100644 --- a/apps/web/src/components/TrackEditor/TrackEditor.js +++ b/apps/web/src/components/TrackEditor/TrackEditor.js @@ -1,10 +1,10 @@ import { isEqual, trim } from 'lodash/fp' -import { Eye, EyeOff, ImagePlus, Plus } from 'lucide-react' +import { Eye, EyeOff, ImagePlus, Info, Lock, LockOpen, Plus } from 'lucide-react' import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' import { push } from 'redux-first-history' -import { useParams, Navigate } from 'react-router-dom' +import { useParams, Navigate, Link } from 'react-router-dom' import Button from 'components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from 'components/ui/select' import HyloEditor from 'components/HyloEditor' @@ -33,6 +33,7 @@ function TrackEditor (props) { const roles = useMemo(() => [...commonRoles.map(role => ({ ...role, type: 'common' })), ...groupRoles.map(role => ({ ...role, type: 'group' }))], [commonRoles, groupRoles]) const [trackState, setTrackState] = useState(Object.assign({ + accessControlled: false, actionDescriptor: currentGroup.settings.actionDescriptor || 'Action', actionDescriptorPlural: currentGroup.settings.actionDescriptorPlural || 'Actions', bannerUrl: '', @@ -49,6 +50,7 @@ function TrackEditor (props) { const [edited, setEdited] = useState(false) const [saving, setSaving] = useState(false) const [errors, setErrors] = useState({}) + const [showAccessControlInfo, setShowAccessControlInfo] = useState(false) const [nameCharacterCount, setNameCharacterCount] = useState(0) const descriptionEditorRef = useRef(null) const welcomeMessageEditorRef = useRef(null) @@ -96,6 +98,7 @@ function TrackEditor (props) { const onSubmit = useCallback(() => { let { + accessControlled, actionDescriptor, actionDescriptorPlural, bannerUrl, @@ -120,6 +123,7 @@ function TrackEditor (props) { const save = editingTrack ? updateTrack : createTrack dispatch(save({ + accessControlled, actionDescriptor, actionDescriptorPlural, bannerUrl, @@ -149,7 +153,7 @@ function TrackEditor (props) { return } - const { actionDescriptor, actionDescriptorPlural, bannerUrl, completionRole, completionMessage, description, name, publishedAt, welcomeMessage } = trackState + const { accessControlled, actionDescriptor, actionDescriptorPlural, bannerUrl, completionRole, completionMessage, description, name, publishedAt, welcomeMessage } = trackState return (
@@ -334,6 +338,43 @@ function TrackEditor (props) {
+
+
+ + {accessControlled ? t('Access Controlled') : t('Free Access')} + +
+ {showAccessControlInfo && ( +

+ {t('When enabled, users will need to purchase access or be granted access by an admin before they can enroll in this track.')} +

+ )} + {accessControlled && currentGroup.stripeAccountId && ( +

+ {t('You must create an offering that includes this track for users to purchase access.')}{' '} + + {t('Go to Paid Content Settings')} + +

+ )} +
+
+ ) + } + return (
+ {/* Display assigned role if invitation includes one */} + {invitationRole && ( +
+
+ {invitationRole.emoji && {invitationRole.emoji}} + + {t('When you join, you will receive the {{roleName}} role', { roleName: invitationRole.name })} + +
+
+ )} {group.suggestedSkills && group.suggestedSkills.length > 0 && } {group.prerequisiteGroups && group.prerequisiteGroups.length > 0 @@ -64,14 +89,17 @@ export default function JoinSection ({ addSkill, currentUser, fullPage, group, g : group.accessibility === GROUP_ACCESSIBILITY.Open ? : group.accessibility === GROUP_ACCESSIBILITY.Restricted - ? hasPendingRequest - ? ( -
-

{t('Request to join pending')}

- {t('You will be sent an email and notified on your device when the request is approved.')} -
- ) - : + ? hasInvitation + // Pre-approved: user has invitation, can join directly + ? + : hasPendingRequest + ? ( +
+

{t('Request to join pending')}

+ {t('You will be sent an email and notified on your device when the request is approved.')} +
+ ) + : : ''}
) @@ -81,6 +109,17 @@ function JoinQuestionsAndButtons ({ group, joinGroup, joinText, t }) { const [questionAnswers, setQuestionAnswers] = useState(group.joinQuestions.map(q => { return { questionId: q.questionId, text: q.text, answer: '' } })) const [allQuestionsAnswered, setAllQuestionsAnswered] = useState(!group.settings.askJoinQuestions || questionAnswers.length === 0) + // Track agreement acceptance - initialize all as unchecked + const agreements = group.agreements || [] + const [acceptedAgreements, setAcceptedAgreements] = useState(agreements.map(() => false)) + const allAgreementsAccepted = agreements.length === 0 || acceptedAgreements.every(a => a) + + // Toggle behavior: barriers are hidden until first button click + const hasAgreements = agreements.length > 0 + const hasRequiredQuestions = group.settings.askJoinQuestions && group.joinQuestions?.length > 0 + const hasBarriers = hasAgreements || hasRequiredQuestions + const [barriersExpanded, setBarriersExpanded] = useState(!hasBarriers) // Start expanded only if no barriers + const setAnswer = (index) => (event) => { const answerValue = event.target.value setQuestionAnswers(prevAnswers => { @@ -91,26 +130,211 @@ function JoinQuestionsAndButtons ({ group, joinGroup, joinText, t }) { }) } + const toggleAgreement = (index) => () => { + setAcceptedAgreements(prev => { + const newState = [...prev] + newState[index] = !newState[index] + return newState + }) + } + + const canJoin = allQuestionsAnswered && allAgreementsAccepted + + const getDisabledReason = () => { + if (!allQuestionsAnswered && !allAgreementsAccepted) { + return t('You must answer all questions and accept all agreements to join') + } + if (!allQuestionsAnswered) { + return t('You must answer all the questions to join') + } + if (!allAgreementsAccepted) { + return t('You must accept all agreements to join') + } + return '' + } + + // Handle button click: expand barriers on first click, join on subsequent clicks + const handleButtonClick = () => { + if (hasBarriers && !barriersExpanded) { + // First click with barriers: expand the barriers UI + setBarriersExpanded(true) + } else if (canJoin) { + // All barriers satisfied: perform the join + joinGroup(group.id, questionAnswers) + } + } + + // Determine button state and text + const getButtonText = () => { + if (hasBarriers && !barriersExpanded) { + return joinText // Show original join text for initial state + } + return joinText + } + + const isButtonDisabled = barriersExpanded && !canJoin + return (
- {group.settings.askJoinQuestions && questionAnswers.length > 0 &&
{t('Please answer the following to join')}:
} - {group.settings.askJoinQuestions && questionAnswers.map((q, index) => ( -
-

{q.text}

-