feat(pg-account-link): check for expired memberships on every hour and fixes

This commit is contained in:
Artur
2025-01-03 17:24:24 -03:00
parent aa46b7b8e6
commit b2f9d89afb
9 changed files with 178 additions and 81 deletions

View File

@@ -45,29 +45,45 @@ export async function getServerSideProps({ query, req, res }: GetServerSideProps
// Decode base64 SSO payload
const ssoPayloadStr = atob(sso as string)
const ssoPayload = new URLSearchParams(ssoPayloadStr)
const nonce = ssoPayload.get('nonce')
const discourseUsername = ssoPayload.get('username')
const nonce = ssoPayload.get('nonce')!
const discourseUsername = ssoPayload.get('username')!
// Check if nonce is valid
if (expectedNonce !== nonce) {
return { props: { success: false } }
}
// Check if username is in use
const accountConnectionWithUsername = await prisma.accountConnection.findFirst({
where: { type: 'privacyGuidesForum', externalId: discourseUsername },
})
if (accountConnectionWithUsername) {
return { props: { success: false } }
}
// Check if user has an active PG membership
const membership = await prisma.donation.findFirst({
where: { userId, membershipExpiresAt: { gt: new Date() } },
where: { userId, fundSlug: 'privacyguides', membershipExpiresAt: { gt: new Date() } },
})
// Add PG forum user to membership group
if (membership) {
await privacyGuidesDiscourseApi.put(
`/groups/${env.PRIVACYGUIDES_DISCOURSE_MEMBERSHIP_GROUP_ID}/members.json`,
{
usernames: discourseUsername,
}
{ usernames: discourseUsername }
)
}
await prisma.accountConnection.create({
data: {
type: 'privacyGuidesForum',
userId,
externalId: discourseUsername,
privacyGuidesAccountIsInMemberGroup: !!membership,
},
})
await keycloak.users.update(
{ id: userId },
{
@@ -75,7 +91,6 @@ export async function getServerSideProps({ query, req, res }: GetServerSideProps
attributes: {
...user.attributes,
privacyGuidesDiscourseLinkNonce: null,
privacyGuidesDiscourseUsername: discourseUsername,
},
}
)

View File

@@ -0,0 +1,21 @@
-- CreateEnum
CREATE TYPE "AccountConnectionType" AS ENUM ('privacyGuidesForum');
-- CreateTable
CREATE TABLE "AccountConnection" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"type" "AccountConnectionType" NOT NULL,
"userId" TEXT NOT NULL,
"externalId" TEXT NOT NULL,
"privacyGuidesAccountIsInMemberGroup" BOOLEAN,
CONSTRAINT "AccountConnection_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AccountConnection_userId_idx" ON "AccountConnection"("userId");
-- CreateIndex
CREATE INDEX "AccountConnection_externalId_idx" ON "AccountConnection"("externalId");

View File

@@ -21,6 +21,10 @@ enum FundSlug {
general
}
enum AccountConnectionType {
privacyGuidesForum
}
model Donation {
id String @id @default(cuid())
createdAt DateTime @default(now())
@@ -63,3 +67,17 @@ model ProjectAddresses {
@@unique([projectSlug, fundSlug])
}
model AccountConnection {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
type AccountConnectionType
userId String
externalId String
privacyGuidesAccountIsInMemberGroup Boolean?
@@index([userId])
@@index([externalId])
}

View File

@@ -1,8 +1,18 @@
import { Queue } from 'bullmq'
import { PerkPurchaseWorkerData } from './workers/perk'
import { redisConnection } from '../config/redis'
import { redisConnection as connection } from '../config/redis'
import './workers/perk'
import './workers/membership-check'
export const perkPurchaseQueue = new Queue<PerkPurchaseWorkerData>('PerkPurchase', {
connection: redisConnection,
connection,
})
export const membershipCheckQueue = new Queue('MembershipCheck', { connection })
membershipCheckQueue.upsertJobScheduler(
'MembershipCheckScheduler',
{ pattern: '0 * * * *' },
{ name: 'MembershipCheck' }
)

View File

@@ -8,7 +8,7 @@ import crypto from 'crypto'
import { protectedProcedure, router } from '../trpc'
import { env } from '../../env.mjs'
import { KeycloakJwtPayload, UserSettingsJwtPayload } from '../types'
import { keycloak, privacyGuidesDiscourseApi, transporter } from '../services'
import { keycloak, prisma, privacyGuidesDiscourseApi, transporter } from '../services'
import { authenticateKeycloakClient } from '../utils/keycloak'
import { fundSlugs } from '../../utils/funds'
@@ -203,6 +203,10 @@ export const accountRouter = router({
message: 'USER_NOT_FOUND',
})
const pgAccountConnection = await prisma.accountConnection.findFirst({
where: { type: 'privacyGuidesForum', userId },
})
return {
company: (user.attributes?.company?.[0] as string) || '',
addressLine1: (user.attributes?.addressLine1?.[0] as string) || '',
@@ -211,8 +215,7 @@ export const accountRouter = router({
addressCity: (user.attributes?.addressCity?.[0] as string) || '',
addressState: (user.attributes?.addressState?.[0] as string) || '',
addressCountry: (user.attributes?.addressCountry?.[0] as string) || '',
privacyGuidesDiscourseUsername:
(user.attributes?.privacyGuidesDiscourseUsername?.[0] as string) || '',
privacyGuidesDiscourseUsername: pgAccountConnection?.externalId,
}
}),
@@ -228,7 +231,11 @@ export const accountRouter = router({
message: 'USER_NOT_FOUND',
})
if (user.attributes?.privacyGuidesDiscourseUsername)
const existingAccountConnection = await prisma.accountConnection.findFirst({
where: { type: 'privacyGuidesForum', userId },
})
if (existingAccountConnection)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Account already linked.',
@@ -270,39 +277,23 @@ export const accountRouter = router({
}),
unlinkPrivacyGuidesAccount: protectedProcedure.mutation(async ({ ctx }) => {
await authenticateKeycloakClient()
const userId = ctx.session.user.sub
const user = await keycloak.users.findOne({ id: userId })
if (!user || !user.id)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'USER_NOT_FOUND',
})
const accountConnection = await prisma.accountConnection.findFirst({
where: { type: 'privacyGuidesForum', userId },
})
const discourseUsername = user.attributes?.privacyGuidesDiscourseUsername[0] as
| string
| undefined
if (!accountConnection) return
if (!discourseUsername) return
if (accountConnection.privacyGuidesAccountIsInMemberGroup) {
await privacyGuidesDiscourseApi.delete(
`/groups/${env.PRIVACYGUIDES_DISCOURSE_MEMBERSHIP_GROUP_ID}/members.json`,
{
data: { usernames: accountConnection.externalId },
}
)
}
await privacyGuidesDiscourseApi.delete(
`/groups/${env.PRIVACYGUIDES_DISCOURSE_MEMBERSHIP_GROUP_ID}/members.json`,
{
data: { usernames: discourseUsername },
}
)
await keycloak.users.update(
{ id: userId },
{
...user,
attributes: {
...user.attributes,
privacyGuidesDiscourseUsername: null,
},
}
)
await prisma.accountConnection.delete({ where: { id: accountConnection.id } })
}),
})

View File

@@ -2,7 +2,7 @@ import { z } from 'zod'
import { protectedProcedure, publicProcedure, router } from '../trpc'
import { QueueEvents } from 'bullmq'
import { fundSlugs } from '../../utils/funds'
import { keycloak, printfulApi, prisma, strapiApi } from '../services'
import { keycloak, printfulApi, strapiApi } from '../services'
import {
PrintfulGetCountriesRes,
PrintfulGetProductRes,

View File

@@ -58,27 +58,17 @@ export function getStripeWebhookHandler(fundSlug: FundSlug, secret: string) {
// Add PG forum user to membership group
if (metadata.isMembership && metadata.fundSlug === 'privacyguides' && metadata.userId) {
await authenticateKeycloakClient()
const accountConnection = await prisma.accountConnection.findFirst({
where: { type: 'privacyGuidesForum', userId: metadata.userId },
})
const user = await keycloak.users.findOne({ id: metadata.userId })
if (!user || !user.id) {
console.error(
`[/api/stripe/${metadata.fundSlug}-webhook] User ${metadata.userId} not found for payment ${paymentIntent.id}`
)
return res.status(400).end()
}
const discourseUsername = user.attributes?.privacyGuidesDiscourseUsername[0] as
| string
| undefined
if (discourseUsername) {
if (
!accountConnection?.privacyGuidesAccountIsInMemberGroup &&
accountConnection?.externalId
) {
await privacyGuidesDiscourseApi.put(
`/groups/${env.PRIVACYGUIDES_DISCOURSE_MEMBERSHIP_GROUP_ID}/members.json`,
{
usernames: discourseUsername,
}
{ usernames: accountConnection.externalId }
)
}
}
@@ -157,27 +147,17 @@ export function getStripeWebhookHandler(fundSlug: FundSlug, secret: string) {
// Add PG forum user to membership group
if (metadata.isMembership && metadata.fundSlug === 'privacyguides' && metadata.userId) {
await authenticateKeycloakClient()
const accountConnection = await prisma.accountConnection.findFirst({
where: { type: 'privacyGuidesForum', userId: metadata.userId },
})
const user = await keycloak.users.findOne({ id: metadata.userId })
if (!user || !user.id) {
console.error(
`[/api/stripe/${metadata.fundSlug}-webhook] User ${metadata.userId} not found for invoice ${invoice.id}`
)
return res.status(400).end()
}
const discourseUsername = user.attributes?.privacyGuidesDiscourseUsername[0] as
| string
| undefined
if (discourseUsername) {
if (
!accountConnection?.privacyGuidesAccountIsInMemberGroup &&
accountConnection?.externalId
) {
await privacyGuidesDiscourseApi.put(
`/groups/${env.PRIVACYGUIDES_DISCOURSE_MEMBERSHIP_GROUP_ID}/members.json`,
{
usernames: discourseUsername,
}
{ usernames: accountConnection.externalId }
)
}
}

View File

@@ -0,0 +1,62 @@
import { Worker } from 'bullmq'
import { redisConnection as connection } from '../../config/redis'
import { prisma, privacyGuidesDiscourseApi } from '../services'
import { env } from '../../env.mjs'
const globalForWorker = global as unknown as { hasInitializedWorkers: boolean }
if (!globalForWorker.hasInitializedWorkers)
new Worker(
'MembershipCheck',
async (job) => {
// Checks for expired PG memberships, and remove forum group members when applicable
const pgAccountConnections = await prisma.accountConnection.findMany({
where: { privacyGuidesAccountIsInMemberGroup: true },
})
const userIds = pgAccountConnections.map((connection) => connection.userId)
console.log('querying...')
const usersActiveMembershipDonations = await prisma.donation.groupBy({
by: ['userId'],
where: {
userId: { in: userIds },
fundSlug: 'privacyguides',
membershipExpiresAt: { gt: new Date() },
},
})
const userIdsWithActiveMembership = new Set(
usersActiveMembershipDonations.map((donation) => donation.userId)
)
const userIdsWithExpiredMembership: string[] = userIds.filter(
(userId) => !userIdsWithActiveMembership.has(userId)
)
const discrouseGroupMembersToRemove = pgAccountConnections
.filter((connection) => userIdsWithExpiredMembership.includes(connection.userId))
.map((connection) => connection.externalId)
await privacyGuidesDiscourseApi.delete(
`/groups/${env.PRIVACYGUIDES_DISCOURSE_MEMBERSHIP_GROUP_ID}/members.json`,
{
data: { usernames: discrouseGroupMembersToRemove.join(',') },
}
)
await prisma.accountConnection.updateMany({
where: {
externalId: { in: discrouseGroupMembersToRemove },
privacyGuidesAccountIsInMemberGroup: true,
},
data: {
privacyGuidesAccountIsInMemberGroup: false,
},
})
},
{ connection }
)
if (process.env.NODE_ENV !== 'production') globalForWorker.hasInitializedWorkers = true

View File

@@ -2,7 +2,7 @@ import { Worker } from 'bullmq'
import { AxiosResponse } from 'axios'
import { TRPCError } from '@trpc/server'
import { redisConnection } from '../../config/redis'
import { redisConnection as connection } from '../../config/redis'
import { estimatePrintfulOrderCost, getUserPointBalance } from '../utils/perks'
import { POINTS_REDEEM_PRICE_USD } from '../../config'
import {
@@ -142,7 +142,7 @@ if (!globalForWorker.hasInitializedWorkers)
: undefined,
})
},
{ connection: redisConnection, concurrency: 1 }
{ connection, concurrency: 1 }
)
if (process.env.NODE_ENV !== 'production') globalForWorker.hasInitializedWorkers = true