diff --git a/pages/privacyguides/account/link-community-account.tsx b/pages/privacyguides/account/link-community-account.tsx index e93de4b..561cb61 100644 --- a/pages/privacyguides/account/link-community-account.tsx +++ b/pages/privacyguides/account/link-community-account.tsx @@ -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, }, } ) diff --git a/prisma/migrations/20250103202318_account_connection/migration.sql b/prisma/migrations/20250103202318_account_connection/migration.sql new file mode 100644 index 0000000..fa252d8 --- /dev/null +++ b/prisma/migrations/20250103202318_account_connection/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 04083c3..b861841 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) +} diff --git a/server/queues.ts b/server/queues.ts index 6caa5e5..41d7f5a 100644 --- a/server/queues.ts +++ b/server/queues.ts @@ -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('PerkPurchase', { - connection: redisConnection, + connection, }) + +export const membershipCheckQueue = new Queue('MembershipCheck', { connection }) + +membershipCheckQueue.upsertJobScheduler( + 'MembershipCheckScheduler', + { pattern: '0 * * * *' }, + { name: 'MembershipCheck' } +) diff --git a/server/routers/account.ts b/server/routers/account.ts index 70a14b9..dc9b4b4 100644 --- a/server/routers/account.ts +++ b/server/routers/account.ts @@ -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 } }) }), }) diff --git a/server/routers/perk.ts b/server/routers/perk.ts index 7bce51e..f991276 100644 --- a/server/routers/perk.ts +++ b/server/routers/perk.ts @@ -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, diff --git a/server/utils/webhooks.ts b/server/utils/webhooks.ts index f6c0e1e..2c0aaa4 100644 --- a/server/utils/webhooks.ts +++ b/server/utils/webhooks.ts @@ -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 } ) } } diff --git a/server/workers/membership-check.ts b/server/workers/membership-check.ts new file mode 100644 index 0000000..d9983ca --- /dev/null +++ b/server/workers/membership-check.ts @@ -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 diff --git a/server/workers/perk.ts b/server/workers/perk.ts index 51f5a00..f9e52c9 100644 --- a/server/workers/perk.ts +++ b/server/workers/perk.ts @@ -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