From 3781f7bd9f3c10f06579c3d86d5092d96370e7a6 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Fri, 12 Jun 2026 16:04:42 +0200 Subject: [PATCH 01/10] feat: delayed access grants --- .../access/graphql/resolvers/AccessGrant.js | 4 + .../access/graphql/resolvers/User.js | 14 +- .../access/lib/AccessScheduler.js | 25 +++ packages/backend-modules/access/lib/grants.js | 181 +++++++++++------- packages/backend-modules/access/lib/mail.js | 20 ++ ...260612120000-grant-begin-interval-down.sql | 7 + ...20260612120000-grant-begin-interval-up.sql | 10 + .../access_recipient_confirmation.html | 89 +++++++++ .../20260612120000-grant-begin-interval.js | 8 + .../translate/translations.json | 8 + 10 files changed, 296 insertions(+), 70 deletions(-) create mode 100644 packages/backend-modules/access/migrations/sqls/20260612120000-grant-begin-interval-down.sql create mode 100644 packages/backend-modules/access/migrations/sqls/20260612120000-grant-begin-interval-up.sql create mode 100644 packages/backend-modules/mail/templates/access_recipient_confirmation.html create mode 100644 packages/backend-modules/migrations/migrations/20260612120000-grant-begin-interval.js diff --git a/packages/backend-modules/access/graphql/resolvers/AccessGrant.js b/packages/backend-modules/access/graphql/resolvers/AccessGrant.js index 25071f81aa..9d2a1a22db 100644 --- a/packages/backend-modules/access/graphql/resolvers/AccessGrant.js +++ b/packages/backend-modules/access/graphql/resolvers/AccessGrant.js @@ -72,6 +72,10 @@ module.exports = { return t('api/access/resolvers/AccessGrant/status/unclaimed') } + if (grant.beginAt && new Date(grant.beginAt) > new Date()) { + 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/lib/AccessScheduler.js b/packages/backend-modules/access/lib/AccessScheduler.js index a62d918608..9c3feff404 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 activateGrants(t, pgdb, redis, mail) await recommendations(t, pgdb, mail) await expireGrants(t, pgdb, mail) await followupGrants(t, pgdb, mail) @@ -79,6 +80,30 @@ 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 activateGrants = async (t, pgdb, redis, mail) => { + debug('activateGrants...') + for (const grant of await grantsLib.findUnactivated(pgdb)) { + const transaction = await pgdb.transactionBegin() + + try { + await grantsLib.activateGrant(grant, t, transaction, redis, mail) + await transaction.transactionCommit() + } catch (e) { + await transaction.transactionRollback() + + debug('rollback', { grant: grant.id }) + + throw e + } + } + 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..604ac93286 100644 --- a/packages/backend-modules/access/lib/grants.js +++ b/packages/backend-modules/access/lib/grants.js @@ -188,21 +188,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 +229,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) } }) } @@ -269,6 +282,35 @@ 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 sanatizedVoucherCode = voucherCode.trim().toUpperCase() + + const grantByVoucherCode = await findByVoucherCode(sanatizedVoucherCode, { + 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, + }) + } else { + await activateGrant(grant, t, pgdb, redis, mail) + } + debug('grant', { grant }) return grant @@ -342,62 +384,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: confirmationEnabled = false } = + mailLib.getConfigEmails('recipient', 'confirmation', 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 (confirmationEnabled) { + await mailLib.sendRecipientConfirmation( + 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 +500,11 @@ const invalidate = async (grant, reason, t, pgdb, mail, requestUserId) => { }) } - if (!(await hasUserActiveMembership(recipient, pgdb)) && sendMail) { + if ( + !!grant.activatedAt && + !(await hasUserActiveMembership(recipient, pgdb)) && + sendMail + ) { const granter = await pgdb.public.users.findOne({ id: grant.granterUserId, }) @@ -680,6 +695,31 @@ const findUnassignedByEmail = async (email, pgdb) => { }) } +const findUnactivated = async (pgdb) => { + debug('findUnactivated') + return pgdb.public.accessGrants.find({ + '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 +739,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 +816,7 @@ module.exports = { grant, claim, request, + activateGrant, revoke, recommendations, invalidate, @@ -786,6 +829,8 @@ module.exports = { regwallTrialStatus, findUnassignedByEmail, + findUnactivated, + findPendingByRecipient, findInvalid, findEmptyRecommendations, findEmptyFollowup, diff --git a/packages/backend-modules/access/lib/mail.js b/packages/backend-modules/access/lib/mail.js index ea2f57e0b9..bebb2bb5a9 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 sendRecipientConfirmation = async ( + granter, + campaign, + recipient, + grant, + t, + pgdb, +) => + sendMail(recipient.email, 'recipient', 'confirmation', { + granter, + recipient, + campaign, + grant, + t, + pgdb, + }) + const sendRecipientExpired = async ( granter, campaign, @@ -343,6 +360,9 @@ module.exports = { // Onboarding sendRecipientOnboarding, + // Confirmation when access begins later (campaign.grantBeginInterval) + sendRecipientConfirmation, + // Offboarding when access expired sendRecipientExpired, 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..7480a5dc6f --- /dev/null +++ b/packages/backend-modules/access/migrations/sqls/20260612120000-grant-begin-interval-up.sql @@ -0,0 +1,10 @@ +ALTER TABLE "accessCampaigns" + ADD COLUMN "grantBeginInterval" interval +; + +ALTER TABLE "accessGrants" + ADD COLUMN "activatedAt" timestamp with time zone +; + +-- backfill: grants that already began count as activated +UPDATE "accessGrants" SET "activatedAt" = "beginAt" WHERE "beginAt" IS NOT NULL; diff --git a/packages/backend-modules/mail/templates/access_recipient_confirmation.html b/packages/backend-modules/mail/templates/access_recipient_confirmation.html new file mode 100644 index 0000000000..c89937093f --- /dev/null +++ b/packages/backend-modules/mail/templates/access_recipient_confirmation.html @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + +
+

Guten Tag

+

+ Vielen Dank für Ihre Anmeldung! Ihr Zugang zur Republik + ist bestätigt. +

+

+ Ihr Zugang beginnt am {{GRANT_BEGIN}} und + dauert bis zum {{GRANT_END}}. Wir melden uns bei Ihnen, + sobald es so weit ist. +

+

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..6a41f05b2f 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/confirmation/subject", + "value": "Ihr Republik-Zugang startet in Kürze" + }, { "key": "api/access/email/recipient/recommendations/subject", "value": "Leseempfehlungen aus der Republik" @@ -1540,6 +1544,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" From 15c7cdd5b01a20734d16546ff535a0eb72d7e55b Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Fri, 12 Jun 2026 16:40:54 +0200 Subject: [PATCH 02/10] feat: add in review email for first time voters --- packages/backend-modules/access/lib/grants.js | 14 +++ packages/backend-modules/access/lib/mail.js | 20 +++++ .../lib/perks/subscribeToMailJourney.js | 14 ++- .../templates/access_recipient_in_review.html | 87 +++++++++++++++++++ .../translate/translations.json | 4 + 5 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 packages/backend-modules/mail/templates/access_recipient_in_review.html diff --git a/packages/backend-modules/access/lib/grants.js b/packages/backend-modules/access/lib/grants.js index 604ac93286..90a06e759d 100644 --- a/packages/backend-modules/access/lib/grants.js +++ b/packages/backend-modules/access/lib/grants.js @@ -405,6 +405,20 @@ const request = async (granter, campaignId, payload, t, pgdb, redis, mail) => { pgdb, ) } + + const { enabled: inReviewEnabled = false } = + mailLib.getConfigEmails('recipient', 'in_review', campaign) || {} + + if (inReviewEnabled) { + await mailLib.sendRecipientInReview( + granter, + campaign, + granter, + grant, + t, + pgdb, + ) + } } else { await activateGrant(grant, t, pgdb, redis, mail) } diff --git a/packages/backend-modules/access/lib/mail.js b/packages/backend-modules/access/lib/mail.js index bebb2bb5a9..09f82e9e06 100644 --- a/packages/backend-modules/access/lib/mail.js +++ b/packages/backend-modules/access/lib/mail.js @@ -86,6 +86,23 @@ const sendRecipientConfirmation = 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, @@ -363,6 +380,9 @@ module.exports = { // Confirmation when access begins later (campaign.grantBeginInterval) sendRecipientConfirmation, + // 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/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/translate/translations.json b/packages/backend-modules/translate/translations.json index 6a41f05b2f..a03cd46785 100755 --- a/packages/backend-modules/translate/translations.json +++ b/packages/backend-modules/translate/translations.json @@ -1408,6 +1408,10 @@ "key": "api/access/email/recipient/confirmation/subject", "value": "Ihr Republik-Zugang startet in Kürze" }, + { + "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" From 1c157c9c61b04a4e82e3e44c509d738b8c8ed127 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Fri, 12 Jun 2026 17:29:54 +0200 Subject: [PATCH 03/10] chore: remove unused email --- packages/backend-modules/access/lib/grants.js | 14 --- packages/backend-modules/access/lib/mail.js | 20 ----- .../access_recipient_confirmation.html | 89 ------------------- .../translate/translations.json | 4 - 4 files changed, 127 deletions(-) delete mode 100644 packages/backend-modules/mail/templates/access_recipient_confirmation.html diff --git a/packages/backend-modules/access/lib/grants.js b/packages/backend-modules/access/lib/grants.js index 90a06e759d..965ed0accc 100644 --- a/packages/backend-modules/access/lib/grants.js +++ b/packages/backend-modules/access/lib/grants.js @@ -392,20 +392,6 @@ const request = async (granter, campaignId, payload, t, pgdb, redis, mail) => { beginAt: grant.beginAt, }) - const { enabled: confirmationEnabled = false } = - mailLib.getConfigEmails('recipient', 'confirmation', campaign) || {} - - if (confirmationEnabled) { - await mailLib.sendRecipientConfirmation( - granter, - campaign, - granter, - grant, - t, - pgdb, - ) - } - const { enabled: inReviewEnabled = false } = mailLib.getConfigEmails('recipient', 'in_review', campaign) || {} diff --git a/packages/backend-modules/access/lib/mail.js b/packages/backend-modules/access/lib/mail.js index 09f82e9e06..466dafdb6c 100644 --- a/packages/backend-modules/access/lib/mail.js +++ b/packages/backend-modules/access/lib/mail.js @@ -69,23 +69,6 @@ const sendRecipientOnboarding = async ( pgdb, }) -const sendRecipientConfirmation = async ( - granter, - campaign, - recipient, - grant, - t, - pgdb, -) => - sendMail(recipient.email, 'recipient', 'confirmation', { - granter, - recipient, - campaign, - grant, - t, - pgdb, - }) - const sendRecipientInReview = async ( granter, campaign, @@ -377,9 +360,6 @@ module.exports = { // Onboarding sendRecipientOnboarding, - // Confirmation when access begins later (campaign.grantBeginInterval) - sendRecipientConfirmation, - // Notice that a request is being reviewed (campaign.grantBeginInterval) sendRecipientInReview, diff --git a/packages/backend-modules/mail/templates/access_recipient_confirmation.html b/packages/backend-modules/mail/templates/access_recipient_confirmation.html deleted file mode 100644 index c89937093f..0000000000 --- a/packages/backend-modules/mail/templates/access_recipient_confirmation.html +++ /dev/null @@ -1,89 +0,0 @@ - - - - - - - - - - - - - - - - - -
- - - - - - - - - -
-

Guten Tag

-

- Vielen Dank für Ihre Anmeldung! Ihr Zugang zur Republik - ist bestätigt. -

-

- Ihr Zugang beginnt am {{GRANT_BEGIN}} und - dauert bis zum {{GRANT_END}}. Wir melden uns bei Ihnen, - sobald es so weit ist. -

-

Ihre Crew der Republik

-
-

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

-
-
- - diff --git a/packages/backend-modules/translate/translations.json b/packages/backend-modules/translate/translations.json index a03cd46785..e9b74324cb 100755 --- a/packages/backend-modules/translate/translations.json +++ b/packages/backend-modules/translate/translations.json @@ -1404,10 +1404,6 @@ "key": "api/access/email/recipient/onboarding_gifted_membership/subject", "value": "Willkommen an Bord" }, - { - "key": "api/access/email/recipient/confirmation/subject", - "value": "Ihr Republik-Zugang startet in Kürze" - }, { "key": "api/access/email/recipient/in_review/subject", "value": "Ihre Anfrage wird geprüft" From b7b333b83c09f913415981add822d75482ba218d Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Tue, 16 Jun 2026 14:51:14 +0200 Subject: [PATCH 04/10] Apply suggestions from code review Co-authored-by: Jeremy Stucki --- packages/backend-modules/access/lib/grants.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend-modules/access/lib/grants.js b/packages/backend-modules/access/lib/grants.js index 965ed0accc..bdd2b4c272 100644 --- a/packages/backend-modules/access/lib/grants.js +++ b/packages/backend-modules/access/lib/grants.js @@ -288,9 +288,9 @@ const activateGrant = async (grant, t, pgdb, redis, mail) => { } const claim = async (voucherCode, payload, user, t, pgdb, redis, mail) => { - const sanatizedVoucherCode = voucherCode.trim().toUpperCase() + const sanitizedVoucherCode = voucherCode.trim().toUpperCase() - const grantByVoucherCode = await findByVoucherCode(sanatizedVoucherCode, { + const grantByVoucherCode = await findByVoucherCode(sanitizedVoucherCode, { pgdb, }) From 17911757eb8c1f61904af79a454de3bb7ff9d1a7 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Wed, 17 Jun 2026 17:02:36 +0200 Subject: [PATCH 05/10] chore: enshure access can only be claimed by non members --- .../resolvers/_mutations/claimAccess.js | 19 +--- .../resolvers/_mutations/requestAccess.js | 4 +- packages/backend-modules/access/lib/grants.js | 42 +++++++-- .../access_granter_claim_notice_3months.html | 87 +++++++++++++++++++ .../translate/translations.json | 6 +- 5 files changed, 134 insertions(+), 24 deletions(-) create mode 100644 packages/backend-modules/mail/templates/access_granter_claim_notice_3months.html 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/grants.js b/packages/backend-modules/access/lib/grants.js index bdd2b4c272..55355949a5 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 = [] @@ -664,10 +686,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 } @@ -680,7 +705,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) => { @@ -834,4 +864,6 @@ module.exports = { findInvalid, findEmptyRecommendations, findEmptyFollowup, + + ensureUserHasNoActiveMembershipOrSubscription, } 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/translate/translations.json b/packages/backend-modules/translate/translations.json index e9b74324cb..9b735fb391 100755 --- a/packages/backend-modules/translate/translations.json +++ b/packages/backend-modules/translate/translations.json @@ -1562,7 +1562,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", From f5b9e43ba2b92db0751f1288bc425f4847711659 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Wed, 17 Jun 2026 17:16:16 +0200 Subject: [PATCH 06/10] chore: add missing translation --- packages/backend-modules/translate/translations.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/backend-modules/translate/translations.json b/packages/backend-modules/translate/translations.json index 9b735fb391..dce456b711 100755 --- a/packages/backend-modules/translate/translations.json +++ b/packages/backend-modules/translate/translations.json @@ -1436,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." From bc816bdb0a46dfc253d8ea201d1c0a7762d7e4c2 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Fri, 19 Jun 2026 10:57:06 +0200 Subject: [PATCH 07/10] fix: address pr feedback about the use of new Date --- .../backend-modules/access/graphql/resolvers/AccessGrant.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/backend-modules/access/graphql/resolvers/AccessGrant.js b/packages/backend-modules/access/graphql/resolvers/AccessGrant.js index 9d2a1a22db..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,7 +74,7 @@ module.exports = { return t('api/access/resolvers/AccessGrant/status/unclaimed') } - if (grant.beginAt && new Date(grant.beginAt) > new Date()) { + if (grant.beginAt && moment(grant.beginAt).isAfter(moment())) { return t('api/access/resolvers/AccessGrant/status/pending') } From 5d50c92c104932c990d2e54f4c3dc1c589ac82f1 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Fri, 19 Jun 2026 13:40:34 +0200 Subject: [PATCH 08/10] fix: only check for deferred campaigns when activating deferred --- .../backend-modules/access/lib/AccessScheduler.js | 6 +++--- packages/backend-modules/access/lib/grants.js | 13 ++++++++++--- .../sqls/20260612120000-grant-begin-interval-up.sql | 3 --- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/backend-modules/access/lib/AccessScheduler.js b/packages/backend-modules/access/lib/AccessScheduler.js index 9c3feff404..f6b6a24667 100644 --- a/packages/backend-modules/access/lib/AccessScheduler.js +++ b/packages/backend-modules/access/lib/AccessScheduler.js @@ -33,7 +33,7 @@ const init = async (context) => { 1000 * intervalSecs, ) - await activateGrants(t, pgdb, redis, mail) + await activateDeferredGrants(t, pgdb, redis, mail) await recommendations(t, pgdb, mail) await expireGrants(t, pgdb, mail) await followupGrants(t, pgdb, mail) @@ -85,9 +85,9 @@ module.exports = { init } * grantBeginInterval: applies perks, member role and onboarding emails * once beginAt is reached. */ -const activateGrants = async (t, pgdb, redis, mail) => { +const activateDeferredGrants = async (t, pgdb, redis, mail) => { debug('activateGrants...') - for (const grant of await grantsLib.findUnactivated(pgdb)) { + for (const grant of await grantsLib.findUnactivatedDeferred(pgdb)) { const transaction = await pgdb.transactionBegin() try { diff --git a/packages/backend-modules/access/lib/grants.js b/packages/backend-modules/access/lib/grants.js index 55355949a5..e70a915407 100644 --- a/packages/backend-modules/access/lib/grants.js +++ b/packages/backend-modules/access/lib/grants.js @@ -725,9 +725,16 @@ const findUnassignedByEmail = async (email, pgdb) => { }) } -const findUnactivated = async (pgdb) => { - debug('findUnactivated') +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, @@ -859,7 +866,7 @@ module.exports = { regwallTrialStatus, findUnassignedByEmail, - findUnactivated, + findUnactivatedDeferred, findPendingByRecipient, findInvalid, findEmptyRecommendations, 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 index 7480a5dc6f..188edb1af8 100644 --- 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 @@ -5,6 +5,3 @@ ALTER TABLE "accessCampaigns" ALTER TABLE "accessGrants" ADD COLUMN "activatedAt" timestamp with time zone ; - --- backfill: grants that already began count as activated -UPDATE "accessGrants" SET "activatedAt" = "beginAt" WHERE "beginAt" IS NOT NULL; From 9a6b377dc3e8122b9e01bb85f10c726351b6aadc Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Fri, 19 Jun 2026 14:15:57 +0200 Subject: [PATCH 09/10] chore: code review fixes --- .../backend-modules/access/lib/AccessScheduler.js | 7 ++++--- packages/backend-modules/access/lib/grants.js | 14 +++++++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/backend-modules/access/lib/AccessScheduler.js b/packages/backend-modules/access/lib/AccessScheduler.js index f6b6a24667..7f8eb1f4b5 100644 --- a/packages/backend-modules/access/lib/AccessScheduler.js +++ b/packages/backend-modules/access/lib/AccessScheduler.js @@ -96,9 +96,10 @@ const activateDeferredGrants = async (t, pgdb, redis, mail) => { } catch (e) { await transaction.transactionRollback() - debug('rollback', { grant: grant.id }) - - throw e + console.error('activateDeferredGrants, grant failed', { + error: e, + grant: grant.id, + }) } } debug('activateGrants done') diff --git a/packages/backend-modules/access/lib/grants.js b/packages/backend-modules/access/lib/grants.js index e70a915407..21f69bf914 100644 --- a/packages/backend-modules/access/lib/grants.js +++ b/packages/backend-modules/access/lib/grants.js @@ -269,7 +269,7 @@ const activateGrant = async (grant, 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({ @@ -329,6 +329,18 @@ const claim = async (voucherCode, payload, user, t, pgdb, redis, mail) => { id: grant.id, beginAt: grant.beginAt, }) + + const [campaign, granter] = await Promise.all([ + campaignsLib.findOne(grant.accessCampaignId, pgdb), + pgdb.public.users.findOne({ id: grant.granterUserId }), + ]) + + 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) } From 0400c2d5caa4e19a20eb403da266b7aaa01af458 Mon Sep 17 00:00:00 2001 From: Henning Dahlheim Date: Fri, 19 Jun 2026 14:26:09 +0200 Subject: [PATCH 10/10] fix: exprie email not sent out for non deferred grants --- packages/backend-modules/access/lib/grants.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/backend-modules/access/lib/grants.js b/packages/backend-modules/access/lib/grants.js index 21f69bf914..e29ce2c227 100644 --- a/packages/backend-modules/access/lib/grants.js +++ b/packages/backend-modules/access/lib/grants.js @@ -330,10 +330,7 @@ const claim = async (voucherCode, payload, user, t, pgdb, redis, mail) => { beginAt: grant.beginAt, }) - const [campaign, granter] = await Promise.all([ - campaignsLib.findOne(grant.accessCampaignId, pgdb), - pgdb.public.users.findOne({ id: grant.granterUserId }), - ]) + const { campaign, granter } = grant const { enabled: inReviewEnabled = false } = mailLib.getConfigEmails('recipient', 'in_review', campaign) || {} @@ -534,8 +531,11 @@ const invalidate = async (grant, reason, t, pgdb, mail, requestUserId) => { }) } + const wasActivated = + !!grant.activatedAt || moment(grant.beginAt).isSameOrBefore(moment()) + if ( - !!grant.activatedAt && + wasActivated && !(await hasUserActiveMembership(recipient, pgdb)) && sendMail ) {