diff --git a/packages/backend-modules/access/graphql/resolvers/AccessGrant.js b/packages/backend-modules/access/graphql/resolvers/AccessGrant.js index 25071f81aa..4add6ab4ec 100644 --- a/packages/backend-modules/access/graphql/resolvers/AccessGrant.js +++ b/packages/backend-modules/access/graphql/resolvers/AccessGrant.js @@ -1,3 +1,5 @@ +const moment = require('moment') + const campaignsLib = require('../../lib/campaigns') const eventsLib = require('../../lib/events') @@ -72,6 +74,10 @@ module.exports = { return t('api/access/resolvers/AccessGrant/status/unclaimed') } + if (grant.beginAt && moment(grant.beginAt).isAfter(moment())) { + return t('api/access/resolvers/AccessGrant/status/pending') + } + return t('api/access/resolvers/AccessGrant/status/valid') }, events: (grant, args, { user: me, pgdb }) => { diff --git a/packages/backend-modules/access/graphql/resolvers/User.js b/packages/backend-modules/access/graphql/resolvers/User.js index 8dfd28f7d5..993d682b26 100644 --- a/packages/backend-modules/access/graphql/resolvers/User.js +++ b/packages/backend-modules/access/graphql/resolvers/User.js @@ -15,9 +15,19 @@ module.exports = { const grants = await grantsLib.findByRecipient(user, { withPast, pgdb }) - debug('accessGrants', { user: user.id, grants: grants.length }) + // include grants which were requested but begin in the future + // (campaigns with a grantBeginInterval); withPast already covers them + const pendingGrants = withPast + ? [] + : await grantsLib.findPendingByRecipient(user, { pgdb }) + + debug('accessGrants', { + user: user.id, + grants: grants.length, + pendingGrants: pendingGrants.length, + }) - return grants + return [...pendingGrants, ...grants] }, accessCampaigns: async (user, { withPast }, { user: me, pgdb }) => { if (!Roles.userIsMeOrInRoles(user, me, PRIVILEDGED_ROLES)) { diff --git a/packages/backend-modules/access/graphql/resolvers/_mutations/claimAccess.js b/packages/backend-modules/access/graphql/resolvers/_mutations/claimAccess.js index cdacef964a..63260435ab 100644 --- a/packages/backend-modules/access/graphql/resolvers/_mutations/claimAccess.js +++ b/packages/backend-modules/access/graphql/resolvers/_mutations/claimAccess.js @@ -2,7 +2,7 @@ const debug = require('debug')('access:mutation:claimAccess') const { ensureSignedIn } = require('@orbiting/backend-modules-auth') -const { claim } = require('../../../lib/grants') +const { claim, ensureUserHasNoActiveMembershipOrSubscription } = require('../../../lib/grants') module.exports = async ( _, @@ -10,7 +10,7 @@ module.exports = async ( { req, user, pgdb, redis, t, mail }, ) => { ensureSignedIn(req) - await ensureUserHasNoNewSubscription(user, pgdb, t) + await ensureUserHasNoActiveMembershipOrSubscription(user, pgdb, t) debug('begin', { voucherCode, user: user.id }) const transaction = await pgdb.transactionBegin() @@ -39,18 +39,3 @@ module.exports = async ( } } -async function ensureUserHasNoNewSubscription(user, pgdb, t) { - const result = await pgdb.payments.subscriptions.findFirst( - { - userId: user.id, - status: ['active', 'past_due', 'unpaid', 'paused'], - }, - { fields: ['id'] }, - ) - - if (result) { - throw new Error( - t('api/access/claim/can-not-claim-access-with-active-subscription'), - ) - } -} diff --git a/packages/backend-modules/access/graphql/resolvers/_mutations/requestAccess.js b/packages/backend-modules/access/graphql/resolvers/_mutations/requestAccess.js index a9e197628e..c84ebe7b0a 100644 --- a/packages/backend-modules/access/graphql/resolvers/_mutations/requestAccess.js +++ b/packages/backend-modules/access/graphql/resolvers/_mutations/requestAccess.js @@ -2,7 +2,7 @@ const debug = require('debug')('access:mutation:requestAccess') const { ensureSignedIn } = require('@orbiting/backend-modules-auth') -const { request } = require('../../../lib/grants') +const { request, ensureUserHasNoActiveMembershipOrSubscription } = require('../../../lib/grants') module.exports = async ( _, @@ -10,6 +10,7 @@ module.exports = async ( { req, user, pgdb, redis, t, mail }, ) => { ensureSignedIn(req) + await ensureUserHasNoActiveMembershipOrSubscription(user, pgdb, t) debug('begin', { campaignId, user: user.id }) const transaction = await pgdb.transactionBegin() @@ -37,3 +38,4 @@ module.exports = async ( throw e } } + diff --git a/packages/backend-modules/access/lib/AccessScheduler.js b/packages/backend-modules/access/lib/AccessScheduler.js index a62d918608..7f8eb1f4b5 100644 --- a/packages/backend-modules/access/lib/AccessScheduler.js +++ b/packages/backend-modules/access/lib/AccessScheduler.js @@ -33,6 +33,7 @@ const init = async (context) => { 1000 * intervalSecs, ) + await activateDeferredGrants(t, pgdb, redis, mail) await recommendations(t, pgdb, mail) await expireGrants(t, pgdb, mail) await followupGrants(t, pgdb, mail) @@ -79,6 +80,31 @@ const init = async (context) => { module.exports = { init } +/** + * Activates begun grants whose activation was deferred by a campaign's + * grantBeginInterval: applies perks, member role and onboarding emails + * once beginAt is reached. + */ +const activateDeferredGrants = async (t, pgdb, redis, mail) => { + debug('activateGrants...') + for (const grant of await grantsLib.findUnactivatedDeferred(pgdb)) { + const transaction = await pgdb.transactionBegin() + + try { + await grantsLib.activateGrant(grant, t, transaction, redis, mail) + await transaction.transactionCommit() + } catch (e) { + await transaction.transactionRollback() + + console.error('activateDeferredGrants, grant failed', { + error: e, + grant: grant.id, + }) + } + } + debug('activateGrants done') +} + /** * Sends recommendations on current grants (only for campaigns with active recommendations) */ diff --git a/packages/backend-modules/access/lib/grants.js b/packages/backend-modules/access/lib/grants.js index bf033ecfa2..e29ce2c227 100644 --- a/packages/backend-modules/access/lib/grants.js +++ b/packages/backend-modules/access/lib/grants.js @@ -20,6 +20,28 @@ const VOUCHER_CODE_LENGTH = 5 const { REGWALL_TRIAL_CAMPAIGN_ID } = process.env +const ensureUserHasNoActiveMembershipOrSubscription = async (user, pgdb, t) => { + if (await hasUserActiveMembership(user, pgdb)) { + throw new Error( + t('api/access/claim/can-not-claim-access-with-active-membership'), + ) + } + + const subscription = await pgdb.payments.subscriptions.findFirst( + { + userId: user.id, + status: ['active', 'past_due', 'unpaid', 'paused'], + }, + { fields: ['id'] }, + ) + + if (subscription) { + throw new Error( + t('api/access/claim/can-not-claim-access-with-active-subscription'), + ) + } +} + const evaluateConstraints = async (granter, campaign, email, t, pgdb) => { const errors = [] @@ -188,21 +210,32 @@ const grant = async (granter, campaignId, email, message, t, pgdb, mail) => { return grant } -const claim = async (voucherCode, payload, user, t, pgdb, redis, mail) => { - const sanatizedVoucherCode = voucherCode.trim().toUpperCase() - - const grantByVoucherCode = await findByVoucherCode(sanatizedVoucherCode, { - pgdb, - }) +/** + * Applies a begun grant's side-effects: perks, member role, newsletter + * subscriptions and onboarding/claim notice emails. Runs inline on + * claim/request, or via scheduler once beginAt is reached for campaigns + * with a grantBeginInterval. Guarded by accessGrants.activatedAt. + */ +const activateGrant = async (grant, t, pgdb, redis, mail) => { + const now = moment() + const updated = await pgdb.public.accessGrants.update( + { id: grant.id, activatedAt: null }, + { activatedAt: now, updatedAt: now }, + ) - if (!grantByVoucherCode) { - throw new Error(t('api/access/claim/404')) + if (updated < 1) { + debug('activateGrant, already activated', { id: grant.id }) + return false } - const grant = await beginGrant(grantByVoucherCode, payload, user, pgdb) - await eventsLib.log(grant, 'grant', pgdb) - - const { granter, recipient, campaign } = grant + const campaign = + grant.campaign || (await campaignsLib.findOne(grant.accessCampaignId, pgdb)) + const granter = + grant.granter || + (await pgdb.public.users.findOne({ id: grant.granterUserId })) + const recipient = + grant.recipient || + (await pgdb.public.users.findOne({ id: grant.recipientUserId })) const perks = await grantPerks( grant, @@ -218,10 +251,12 @@ const claim = async (voucherCode, payload, user, t, pgdb, redis, mail) => { await Promise.map(perks, (perk) => { if (perk) { - const { name, ...other } = perk + const { name, eventLogExtend, ...other } = perk grant.perks[perk.name] = other - eventsLib.log(grant, `perk.${name}`, pgdb) + const event = `perk.${name}${eventLogExtend || ''}` + + eventsLib.log(grant, event, pgdb) } }) } @@ -234,7 +269,7 @@ const claim = async (voucherCode, payload, user, t, pgdb, redis, mail) => { const subscribeToEditorialNewsletters = campaign.config?.subscribeToEditorialNewsletters || - perks.some(({ settings }) => !!settings.subscribeToEditorialNewsletters) || + perks.filter(Boolean).some(({ settings }) => !!settings.subscribeToEditorialNewsletters) || hasAddedMemberRole await mail.enforceSubscriptions({ @@ -269,6 +304,44 @@ const claim = async (voucherCode, payload, user, t, pgdb, redis, mail) => { pgdb, ) + debug('activateGrant', { id: grant.id }) + + return true +} + +const claim = async (voucherCode, payload, user, t, pgdb, redis, mail) => { + const sanitizedVoucherCode = voucherCode.trim().toUpperCase() + + const grantByVoucherCode = await findByVoucherCode(sanitizedVoucherCode, { + pgdb, + }) + + if (!grantByVoucherCode) { + throw new Error(t('api/access/claim/404')) + } + + const grant = await beginGrant(grantByVoucherCode, payload, user, pgdb) + await eventsLib.log(grant, 'grant', pgdb) + + if (moment(grant.beginAt).isAfter(moment())) { + // campaign has a grantBeginInterval; scheduler activates at beginAt + debug('claim, activation deferred', { + id: grant.id, + beginAt: grant.beginAt, + }) + + const { campaign, granter } = grant + + const { enabled: inReviewEnabled = false } = + mailLib.getConfigEmails('recipient', 'in_review', campaign) || {} + + if (inReviewEnabled) { + await mailLib.sendRecipientInReview(granter, campaign, user, grant, t, pgdb) + } + } else { + await activateGrant(grant, t, pgdb, redis, mail) + } + debug('grant', { grant }) return grant @@ -342,62 +415,31 @@ const request = async (granter, campaignId, payload, t, pgdb, redis, mail) => { await eventsLib.log(grant, 'request', pgdb) - const perks = await grantPerks(grant, granter, campaign, t, pgdb, redis, mail) - if (perks.length > 0) { - grant.perks = {} - - await Promise.map(perks, (perk) => { - if (perk) { - const { name, eventLogExtend, ...other } = perk - grant.perks[perk.name] = other - - const event = `perk.${name}${eventLogExtend || ''}` - - eventsLib.log(grant, event, pgdb) - } + if (campaign.grantBeginInterval) { + // activation (perks, member role, onboarding email) is deferred to the + // scheduler once beginAt is reached + debug('request, activation deferred', { + id: grant.id, + beginAt: grant.beginAt, }) - } - const hasAddedMemberRole = await membershipsLib.addMemberRole( - grant, - granter, - pgdb, - ) + const { enabled: inReviewEnabled = false } = + mailLib.getConfigEmails('recipient', 'in_review', campaign) || {} - const subscribeToEditorialNewsletters = - campaign.config?.subscribeToEditorialNewsletters || - perks.some(({ settings }) => !!settings.subscribeToEditorialNewsletters) || - hasAddedMemberRole - - await mail.enforceSubscriptions({ - userId: grant.granter.id, - pgdb, - subscribeToEditorialNewsletters, - }) - - const { enabled: onboardingEnabled = false } = - mailLib.getConfigEmails('recipient', 'onboarding', campaign) || {} - - if (!(await hasUserActiveMembership(granter, pgdb)) || !!onboardingEnabled) { - await mailLib.sendRecipientOnboarding( - granter, - campaign, - granter, - grant, - t, - pgdb, - ) + if (inReviewEnabled) { + await mailLib.sendRecipientInReview( + granter, + campaign, + granter, + grant, + t, + pgdb, + ) + } + } else { + await activateGrant(grant, t, pgdb, redis, mail) } - await mailLib.sendGranterClaimNotice( - granter, - campaign, - granter, - grant, - t, - pgdb, - ) - return grant } @@ -489,7 +531,14 @@ const invalidate = async (grant, reason, t, pgdb, mail, requestUserId) => { }) } - if (!(await hasUserActiveMembership(recipient, pgdb)) && sendMail) { + const wasActivated = + !!grant.activatedAt || moment(grant.beginAt).isSameOrBefore(moment()) + + if ( + wasActivated && + !(await hasUserActiveMembership(recipient, pgdb)) && + sendMail + ) { const granter = await pgdb.public.users.findOne({ id: grant.granterUserId, }) @@ -649,10 +698,13 @@ const findByVoucherCode = async (voucherCode, { pgdb }) => { } const regwallTrialStatus = async (user, { pgdb }) => { - const trialGrant = await pgdb.public.accessGrants.findFirst({ - recipientUserId: user.id, - accessCampaignId: REGWALL_TRIAL_CAMPAIGN_ID, - }, {orderBy: {createdAt: 'desc'}}) + const trialGrant = await pgdb.public.accessGrants.findFirst( + { + recipientUserId: user.id, + accessCampaignId: REGWALL_TRIAL_CAMPAIGN_ID, + }, + { orderBy: { createdAt: 'desc' } }, + ) if (!trialGrant) { return null } @@ -665,7 +717,12 @@ const regwallTrialStatus = async (user, { pgdb }) => { const isGrantActive = (grant) => { const now = new Date() - return !grant.invalidatedAt && !grant.revokedAt && grant.beginAt <= now && grant.endAt > now + return ( + !grant.invalidatedAt && + !grant.revokedAt && + grant.beginAt <= now && + grant.endAt > now + ) } const findUnassignedByEmail = async (email, pgdb) => { @@ -680,6 +737,38 @@ const findUnassignedByEmail = async (email, pgdb) => { }) } +const findUnactivatedDeferred = async (pgdb) => { + debug('findUnactivatedDeferred') + const campaignsWithBeginInterval = await pgdb.public.accessCampaigns.find({ + 'grantBeginInterval !=': null, + }) + if (!campaignsWithBeginInterval.length) { + return [] + } + return pgdb.public.accessGrants.find({ + 'accessCampaignId in': campaignsWithBeginInterval.map((c) => c.id), + 'beginAt <=': moment(), + activatedAt: null, + 'recipientUserId !=': null, + invalidatedAt: null, + revokedAt: null, + }) +} + +const findPendingByRecipient = async (recipient, { pgdb }) => { + debug('findPendingByRecipient', { recipient: recipient.id }) + + return pgdb.public.accessGrants.find( + { + recipientUserId: recipient.id, + 'beginAt >': moment(), + invalidatedAt: null, + revokedAt: null, + }, + { orderBy: { createdAt: 'desc' } }, + ) +} + const findInvalid = async (pgdb) => { debug('findInvalid') const now = moment() @@ -699,8 +788,10 @@ const beginGrant = async (grant, payload, recipient, pgdb) => { ]) const now = moment() - const beginAt = now.clone() - const endAt = addInterval(beginAt, campaign.grantPeriodInterval) + const beginAt = campaign.grantBeginInterval + ? addInterval(now.clone(), campaign.grantBeginInterval) + : now.clone() + const endAt = addInterval(beginAt.clone(), campaign.grantPeriodInterval) const updateFields = { recipientUserId: recipient.id, @@ -774,6 +865,7 @@ module.exports = { grant, claim, request, + activateGrant, revoke, recommendations, invalidate, @@ -786,7 +878,11 @@ module.exports = { regwallTrialStatus, findUnassignedByEmail, + findUnactivatedDeferred, + findPendingByRecipient, findInvalid, findEmptyRecommendations, findEmptyFollowup, + + ensureUserHasNoActiveMembershipOrSubscription, } diff --git a/packages/backend-modules/access/lib/mail.js b/packages/backend-modules/access/lib/mail.js index ea2f57e0b9..466dafdb6c 100644 --- a/packages/backend-modules/access/lib/mail.js +++ b/packages/backend-modules/access/lib/mail.js @@ -69,6 +69,23 @@ const sendRecipientOnboarding = async ( pgdb, }) +const sendRecipientInReview = async ( + granter, + campaign, + recipient, + grant, + t, + pgdb, +) => + sendMail(recipient.email, 'recipient', 'in_review', { + granter, + recipient, + campaign, + grant, + t, + pgdb, + }) + const sendRecipientExpired = async ( granter, campaign, @@ -343,6 +360,9 @@ module.exports = { // Onboarding sendRecipientOnboarding, + // Notice that a request is being reviewed (campaign.grantBeginInterval) + sendRecipientInReview, + // Offboarding when access expired sendRecipientExpired, diff --git a/packages/backend-modules/access/lib/perks/subscribeToMailJourney.js b/packages/backend-modules/access/lib/perks/subscribeToMailJourney.js index 44000866ea..9229fc2dfe 100644 --- a/packages/backend-modules/access/lib/perks/subscribeToMailJourney.js +++ b/packages/backend-modules/access/lib/perks/subscribeToMailJourney.js @@ -20,12 +20,18 @@ const give = async ( redis, mail, ) => { - if (!(settings?.audience)) { - throw new Error(`Error while subscribing user to mailchimp journey ${settings?.audience}, valid audience settings are REGWALL_TRIAL and PROBELESEN`) + // audienceId (direct Mailchimp audience id from campaign config) takes + // precedence over the symbolic audience name mapped to env vars + const audienceId = settings?.audienceId || audiences[settings?.audience] + + if (!audienceId) { + throw new Error( + `Error while subscribing user to mailchimp journey: provide settings.audienceId or settings.audience (REGWALL_TRIAL or PROBELESEN, requires the corresponding env var); got ${JSON.stringify( + settings, + )}`, + ) } - const audienceId = audiences[settings.audience] - await mail.addUserToAudience({ user: recipient, audienceId: audienceId, diff --git a/packages/backend-modules/access/migrations/sqls/20260612120000-grant-begin-interval-down.sql b/packages/backend-modules/access/migrations/sqls/20260612120000-grant-begin-interval-down.sql new file mode 100644 index 0000000000..894dacfc3f --- /dev/null +++ b/packages/backend-modules/access/migrations/sqls/20260612120000-grant-begin-interval-down.sql @@ -0,0 +1,7 @@ +ALTER TABLE "accessCampaigns" + DROP COLUMN "grantBeginInterval" +; + +ALTER TABLE "accessGrants" + DROP COLUMN "activatedAt" +; diff --git a/packages/backend-modules/access/migrations/sqls/20260612120000-grant-begin-interval-up.sql b/packages/backend-modules/access/migrations/sqls/20260612120000-grant-begin-interval-up.sql new file mode 100644 index 0000000000..188edb1af8 --- /dev/null +++ b/packages/backend-modules/access/migrations/sqls/20260612120000-grant-begin-interval-up.sql @@ -0,0 +1,7 @@ +ALTER TABLE "accessCampaigns" + ADD COLUMN "grantBeginInterval" interval +; + +ALTER TABLE "accessGrants" + ADD COLUMN "activatedAt" timestamp with time zone +; diff --git a/packages/backend-modules/mail/templates/access_granter_claim_notice_3months.html b/packages/backend-modules/mail/templates/access_granter_claim_notice_3months.html new file mode 100644 index 0000000000..55cfe64ecf --- /dev/null +++ b/packages/backend-modules/mail/templates/access_granter_claim_notice_3months.html @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + +
+

Guten Tag

+

+ Der von Ihnen vermittelte Republik-Zugang wurde soeben eingelöst. +

+

+ Herzlichen Dank, dass Sie unser Magazin weiterempfohlen + haben und damit zur Zukunft der Republik beitragen! +

+

Ihre Crew der Republik

+
+

+ +
+ Republik AG
+ Sihlhallenstrasse 1, CH-8004 Zürich
+ www.republik.ch
+ kontakt@republik.ch +

+
+
+ + diff --git a/packages/backend-modules/mail/templates/access_recipient_in_review.html b/packages/backend-modules/mail/templates/access_recipient_in_review.html new file mode 100644 index 0000000000..4e9955886b --- /dev/null +++ b/packages/backend-modules/mail/templates/access_recipient_in_review.html @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + +
+

Guten Tag

+

+ Vielen Dank für Ihre Anfrage! Wir haben sie erhalten und + prüfen sie derzeit. +

+

+ Sobald die Prüfung abgeschlossen ist, melden wir uns wieder bei Ihnen. +

+

Ihre Crew der Republik

+
+

+ +
+ Republik AG
+ Sihlhallenstrasse 1, CH-8004 Zürich
+ www.republik.ch
+ kontakt@republik.ch +

+
+
+ + diff --git a/packages/backend-modules/migrations/migrations/20260612120000-grant-begin-interval.js b/packages/backend-modules/migrations/migrations/20260612120000-grant-begin-interval.js new file mode 100644 index 0000000000..d1eb51eda2 --- /dev/null +++ b/packages/backend-modules/migrations/migrations/20260612120000-grant-begin-interval.js @@ -0,0 +1,8 @@ +const run = require('../run.js') + +const dir = 'packages/backend-modules/access/migrations/sqls' +const file = '20260612120000-grant-begin-interval' + +exports.up = (db) => run(db, dir, `${file}-up.sql`) + +exports.down = (db) => run(db, dir, `${file}-down.sql`) diff --git a/packages/backend-modules/translate/translations.json b/packages/backend-modules/translate/translations.json index 15a0f9e500..dce456b711 100755 --- a/packages/backend-modules/translate/translations.json +++ b/packages/backend-modules/translate/translations.json @@ -1404,6 +1404,10 @@ "key": "api/access/email/recipient/onboarding_gifted_membership/subject", "value": "Willkommen an Bord" }, + { + "key": "api/access/email/recipient/in_review/subject", + "value": "Ihre Anfrage wird geprüft" + }, { "key": "api/access/email/recipient/recommendations/subject", "value": "Leseempfehlungen aus der Republik" @@ -1432,6 +1436,10 @@ "key": "api/access/email/granter/claim_notice_gifted_membership/subject", "value": "Geschenk-Mitgliedschaft von {recipientName} eingelöst" }, + { + "key": "api/access/email/granter/claim_notice_3months/subject", + "value": "Republik-Zugang wurde eingelöst" + }, { "key": "api/access/grant/email/error", "value": "Wir konnten «{email}» nicht als gültige E-Mail-Adresse erkennen." @@ -1540,6 +1548,10 @@ "key": "api/access/resolvers/AccessGrant/status/unclaimed", "value": "gültig, uneingelöst" }, + { + "key": "api/access/resolvers/AccessGrant/status/pending", + "value": "gültig, noch nicht begonnen" + }, { "key": "api/access/resolvers/AccessGrant/status/valid", "value": "gültig" @@ -1554,7 +1566,11 @@ }, { "key": "api/access/claim/can-not-claim-access-with-active-subscription", - "value": "Ups, es ist ein Fehler aufgetreten. Kontaktieren Sie bitte das Support-Team via kontakt@republik.ch." + "value": "Sie haben bereits ein aktives Abonnement und können deshalb keinen Zugang einlösen." + }, + { + "key": "api/access/claim/can-not-claim-access-with-active-membership", + "value": "Sie haben bereits eine aktive Mitgliedschaft oder ein aktives Abo und können deshalb keinen Zugang einlösen." }, { "key": "api/access/request/campaign/error",