mirror of
https://github.com/MAGICGrants/campaign-site.git
synced 2026-01-08 20:08:05 -05:00
feat(pg-account-link): check for expired memberships on every hour and fixes
This commit is contained in:
@@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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");
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
|
||||
@@ -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 } })
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
62
server/workers/membership-check.ts
Normal file
62
server/workers/membership-check.ts
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user