diff --git a/__tests__/decorations.ts b/__tests__/decorations.ts new file mode 100644 index 0000000000..621b6c7661 --- /dev/null +++ b/__tests__/decorations.ts @@ -0,0 +1,378 @@ +import { + createMockNjordTransport, + disposeGraphQLTesting, + GraphQLTestClient, + GraphQLTestingState, + initializeGraphQLTesting, + MockContext, + saveFixtures, + testMutationError, + testMutationErrorCode, +} from './helpers'; +import { Decoration, User, UserDecoration } from '../src/entity'; +import { usersFixture } from './fixture/user'; +import { DataSource } from 'typeorm'; +import createOrGetConnection from '../src/db'; +import { CoresRole } from '../src/types'; +import * as njordCommon from '../src/common/njord'; +import { createClient } from '@connectrpc/connect'; +import { Credits, EntityType } from '@dailydotdev/schema'; +import { ioRedisPool } from '../src/redis'; +import { + UserTransaction, + UserTransactionType, +} from '../src/entity/user/UserTransaction'; +import { systemUser } from '../src/common'; +import crypto from 'crypto'; + +let con: DataSource; +let state: GraphQLTestingState; +let client: GraphQLTestClient; +let loggedUser: string | null = null; + +const decorationsFixture: Partial[] = [ + { + id: 'purchasable-deco', + name: 'Purchasable Decoration', + media: 'https://example.com/deco1.png', + decorationGroup: 'shop', + unlockCriteria: null, + groupOrder: 0, + active: true, + price: 100, + }, + { + id: 'subscriber-deco', + name: 'Subscriber Decoration', + media: 'https://example.com/deco2.png', + decorationGroup: 'subscriber', + unlockCriteria: 'Be a subscriber', + groupOrder: 0, + active: true, + price: null, + }, + { + id: 'inactive-deco', + name: 'Inactive Decoration', + media: 'https://example.com/deco3.png', + decorationGroup: 'shop', + unlockCriteria: null, + groupOrder: 1, + active: false, + price: 50, + }, + { + id: 'expensive-deco', + name: 'Expensive Decoration', + media: 'https://example.com/deco4.png', + decorationGroup: 'shop', + unlockCriteria: null, + groupOrder: 2, + active: true, + price: 1000, + }, +]; + +beforeAll(async () => { + con = await createOrGetConnection(); + state = await initializeGraphQLTesting( + () => new MockContext(con, loggedUser), + ); + client = state.client; +}); + +beforeEach(async () => { + loggedUser = null; + jest.resetAllMocks(); + + await saveFixtures( + con, + User, + usersFixture.map((item) => ({ + ...item, + id: `${item.id}-deco`, + username: `${item.username}-deco`, + coresRole: CoresRole.User, + })), + ); + await saveFixtures(con, Decoration, decorationsFixture); +}); + +afterAll(() => disposeGraphQLTesting(state)); + +describe('query decorationsByGroup', () => { + const QUERY = ` + query DecorationsbyGroup { + decorationsByGroup { + group + label + decorations { + id + name + media + decorationGroup + unlockCriteria + price + isUnlocked + isPurchasable + } + } + } + `; + + it('should not allow unauthenticated users', () => + testMutationErrorCode(client, { mutation: QUERY }, 'UNAUTHENTICATED')); + + it('should return decorations with price and isPurchasable fields', async () => { + loggedUser = '1-deco'; + + const res = await client.query(QUERY); + expect(res.errors).toBeFalsy(); + + const { decorationsByGroup } = res.data; + expect(decorationsByGroup).toBeDefined(); + + const shopGroup = decorationsByGroup.find( + (g: { group: string }) => g.group === 'shop', + ); + expect(shopGroup).toBeDefined(); + expect(shopGroup.decorations).toHaveLength(2); + + const purchasable = shopGroup.decorations.find( + (d: { id: string }) => d.id === 'purchasable-deco', + ); + expect(purchasable).toMatchObject({ + id: 'purchasable-deco', + name: 'Purchasable Decoration', + price: 100, + isUnlocked: false, + isPurchasable: true, + }); + }); + + it('should mark owned decorations as unlocked and not purchasable', async () => { + loggedUser = '1-deco'; + + await con.getRepository(UserDecoration).insert({ + userId: loggedUser, + decorationId: 'purchasable-deco', + }); + + const res = await client.query(QUERY); + expect(res.errors).toBeFalsy(); + + const { decorationsByGroup } = res.data; + const shopGroup = decorationsByGroup.find( + (g: { group: string }) => g.group === 'shop', + ); + const purchasable = shopGroup.decorations.find( + (d: { id: string }) => d.id === 'purchasable-deco', + ); + + expect(purchasable).toMatchObject({ + isUnlocked: true, + isPurchasable: false, + }); + }); +}); + +describe('mutation purchaseDecoration', () => { + const MUTATION = ` + mutation PurchaseDecoration($decorationId: ID!) { + purchaseDecoration(decorationId: $decorationId) { + decoration { + id + name + price + isUnlocked + isPurchasable + } + balance + } + } + `; + + beforeEach(async () => { + const mockTransport = createMockNjordTransport(); + + jest + .spyOn(njordCommon, 'getNjordClient') + .mockImplementation(() => createClient(Credits, mockTransport)); + + await ioRedisPool.execute((client) => client.flushall()); + }); + + it('should not allow unauthenticated users', () => + testMutationErrorCode( + client, + { + mutation: MUTATION, + variables: { decorationId: 'purchasable-deco' }, + }, + 'UNAUTHENTICATED', + )); + + it('should throw error if user does not have cores access', async () => { + loggedUser = '1-deco'; + + await con + .getRepository(User) + .update({ id: loggedUser }, { coresRole: CoresRole.None }); + + await testMutationError( + client, + { + mutation: MUTATION, + variables: { decorationId: 'purchasable-deco' }, + }, + (errors) => { + expect(errors[0].message).toEqual('You do not have access to Cores'); + }, + ); + }); + + it('should throw error if decoration does not exist', async () => { + loggedUser = '1-deco'; + + await testMutationError( + client, + { + mutation: MUTATION, + variables: { decorationId: 'non-existent' }, + }, + (errors) => { + expect(errors[0].message).toEqual('Decoration not found'); + }, + ); + }); + + it('should throw error if decoration is not active', async () => { + loggedUser = '1-deco'; + + await testMutationError( + client, + { + mutation: MUTATION, + variables: { decorationId: 'inactive-deco' }, + }, + (errors) => { + expect(errors[0].message).toEqual('Decoration not found'); + }, + ); + }); + + it('should throw error if decoration is not purchasable', async () => { + loggedUser = '1-deco'; + + await testMutationError( + client, + { + mutation: MUTATION, + variables: { decorationId: 'subscriber-deco' }, + }, + (errors) => { + expect(errors[0].message).toEqual( + 'This decoration is not available for purchase', + ); + }, + ); + }); + + it('should throw error if user already owns the decoration', async () => { + loggedUser = '1-deco'; + + await con.getRepository(UserDecoration).insert({ + userId: loggedUser, + decorationId: 'purchasable-deco', + }); + + await testMutationError( + client, + { + mutation: MUTATION, + variables: { decorationId: 'purchasable-deco' }, + }, + (errors) => { + expect(errors[0].message).toEqual('You already own this decoration'); + }, + ); + }); + + it('should throw error if user does not have enough cores', async () => { + loggedUser = '1-deco'; + + const testNjordClient = njordCommon.getNjordClient(); + await testNjordClient.transfer({ + idempotencyKey: crypto.randomUUID(), + transfers: [ + { + sender: { id: 'system', type: EntityType.SYSTEM }, + receiver: { id: loggedUser, type: EntityType.USER }, + amount: 50, + }, + ], + }); + + await testMutationError( + client, + { + mutation: MUTATION, + variables: { decorationId: 'purchasable-deco' }, + }, + (errors) => { + expect(errors[0].message).toEqual( + 'Not enough Cores to purchase this decoration', + ); + }, + ); + }); + + it('should successfully purchase decoration when user has enough cores', async () => { + loggedUser = '1-deco'; + + const testNjordClient = njordCommon.getNjordClient(); + await testNjordClient.transfer({ + idempotencyKey: crypto.randomUUID(), + transfers: [ + { + sender: { id: systemUser.id, type: EntityType.SYSTEM }, + receiver: { id: loggedUser, type: EntityType.USER }, + amount: 500, + }, + ], + }); + + const res = await client.mutate(MUTATION, { + variables: { decorationId: 'purchasable-deco' }, + }); + + expect(res.errors).toBeFalsy(); + const { purchaseDecoration } = res.data; + + expect(purchaseDecoration).toMatchObject({ + decoration: { + id: 'purchasable-deco', + name: 'Purchasable Decoration', + price: 100, + isUnlocked: true, + isPurchasable: false, + }, + balance: 400, + }); + + const userDecoration = await con.getRepository(UserDecoration).findOneBy({ + userId: loggedUser, + decorationId: 'purchasable-deco', + }); + expect(userDecoration).toBeDefined(); + + const transaction = await con.getRepository(UserTransaction).findOneBy({ + senderId: loggedUser, + referenceType: UserTransactionType.DecorationPurchase, + referenceId: 'purchasable-deco', + }); + expect(transaction).toBeDefined(); + expect(transaction?.value).toEqual(100); + expect(transaction?.receiverId).toEqual(systemUser.id); + }); +}); diff --git a/src/cron/index.ts b/src/cron/index.ts index 60e0984c1b..bd36ec80e5 100644 --- a/src/cron/index.ts +++ b/src/cron/index.ts @@ -26,6 +26,7 @@ import { userProfileAnalyticsHistoryClickhouseCron } from './userProfileAnalytic import { cleanZombieOpportunities } from './cleanZombieOpportunities'; import { userProfileUpdatedSync } from './userProfileUpdatedSync'; import expireSuperAgentTrial from './expireSuperAgentTrial'; +import unlockSubscriberDecorations from './unlockSubscriberDecorations'; export const crons: Cron[] = [ updateViews, @@ -55,4 +56,5 @@ export const crons: Cron[] = [ cleanZombieOpportunities, userProfileUpdatedSync, expireSuperAgentTrial, + unlockSubscriberDecorations, ]; diff --git a/src/cron/unlockSubscriberDecorations.ts b/src/cron/unlockSubscriberDecorations.ts new file mode 100644 index 0000000000..b7128eee52 --- /dev/null +++ b/src/cron/unlockSubscriberDecorations.ts @@ -0,0 +1,91 @@ +import { Cron } from './cron'; +import { User, UserDecoration } from '../entity'; +import { isPlusMember } from '../paddle'; +import { IsNull, Not } from 'typeorm'; + +// Decoration IDs and their required subscription duration in milliseconds +const SUBSCRIBER_DECORATIONS = [ + { id: 'activesubscriber', durationMs: 0 }, // Immediate + { id: 'threemonth', durationMs: 3 * 30 * 24 * 60 * 60 * 1000 }, // ~3 months + { id: 'sixmonth', durationMs: 6 * 30 * 24 * 60 * 60 * 1000 }, // ~6 months + { id: 'oneyear', durationMs: 365 * 24 * 60 * 60 * 1000 }, // 1 year + { id: 'twoyears', durationMs: 2 * 365 * 24 * 60 * 60 * 1000 }, // 2 years +] as const; + +const cron: Cron = { + name: 'unlock-subscriber-decorations', + handler: async (con, logger) => { + logger.debug('checking subscriber decorations to unlock...'); + + const now = new Date(); + let totalUnlocked = 0; + + // Get all users with Plus subscription + const plusUsers = await con.getRepository(User).find({ + where: { + subscriptionFlags: Not(IsNull()), + }, + select: ['id', 'subscriptionFlags'], + }); + + const activePlusUsers = plusUsers.filter((user) => + isPlusMember(user.subscriptionFlags?.cycle), + ); + + logger.debug( + { count: activePlusUsers.length }, + 'found active Plus members', + ); + + for (const user of activePlusUsers) { + const subscriptionCreatedAt = user.subscriptionFlags?.createdAt; + + if (!subscriptionCreatedAt) { + continue; + } + + const subscriptionStartDate = new Date(subscriptionCreatedAt); + const subscriptionDurationMs = + now.getTime() - subscriptionStartDate.getTime(); + + // Get user's existing decorations + const existingDecorations = await con.getRepository(UserDecoration).find({ + where: { userId: user.id }, + select: ['decorationId'], + }); + const existingDecorationIds = new Set( + existingDecorations.map((ud) => ud.decorationId), + ); + + // Check which decorations user qualifies for + const decorationsToUnlock = SUBSCRIBER_DECORATIONS.filter( + (decoration) => + subscriptionDurationMs >= decoration.durationMs && + !existingDecorationIds.has(decoration.id), + ); + + if (decorationsToUnlock.length > 0) { + await con.getRepository(UserDecoration).insert( + decorationsToUnlock.map((decoration) => ({ + userId: user.id, + decorationId: decoration.id, + })), + ); + + totalUnlocked += decorationsToUnlock.length; + + logger.debug( + { + userId: user.id, + decorations: decorationsToUnlock.map((d) => d.id), + }, + 'unlocked decorations for user', + ); + } + } + + logger.info({ totalUnlocked }, 'subscriber decorations unlock completed'); + }, +}; + +export default cron; diff --git a/src/entity/Decoration.ts b/src/entity/Decoration.ts new file mode 100644 index 0000000000..518c662c26 --- /dev/null +++ b/src/entity/Decoration.ts @@ -0,0 +1,31 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity() +export class Decoration { + @PrimaryColumn({ type: 'text' }) + id: string; + + @Column({ type: 'text' }) + name: string; + + @Column({ type: 'text' }) + media: string; + + @Column({ type: 'text', default: 'subscriber' }) + decorationGroup: string; + + @Column({ type: 'text', nullable: true }) + unlockCriteria: string | null; + + @Column({ type: 'integer', default: 0 }) + groupOrder: number; + + @Column({ type: 'boolean', default: true }) + active: boolean; + + @Column({ type: 'integer', nullable: true }) + price: number | null; + + @Column({ type: 'timestamp', default: () => 'now()' }) + createdAt: Date; +} diff --git a/src/entity/index.ts b/src/entity/index.ts index 2d9abdeb80..7822110f84 100644 --- a/src/entity/index.ts +++ b/src/entity/index.ts @@ -37,3 +37,4 @@ export * from './SquadPublicRequest'; export * from './UserCompany'; export * from './Organization'; export * from './campaign'; +export * from './Decoration'; diff --git a/src/entity/user/User.ts b/src/entity/user/User.ts index 9527e68d58..1d305625d0 100644 --- a/src/entity/user/User.ts +++ b/src/entity/user/User.ts @@ -29,6 +29,7 @@ import type { UserCandidatePreference } from './UserCandidatePreference'; import type { UserCandidateKeyword } from './UserCandidateKeyword'; import type { UserCandidateAnswer } from './UserCandidateAnswer'; import type { DatasetLocation } from '../dataset/DatasetLocation'; +import type { Decoration } from '../Decoration'; export type UserFlags = Partial<{ vordr: boolean; @@ -370,4 +371,11 @@ export class User { // due to user table having a lot of depenencies @Column({ type: 'int', default: 0 }) inc: number; + + @Column({ type: 'text', nullable: true }) + activeDecorationId: string | null; + + @ManyToOne('Decoration', { lazy: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'activeDecorationId' }) + activeDecoration: Promise; } diff --git a/src/entity/user/UserDecoration.ts b/src/entity/user/UserDecoration.ts new file mode 100644 index 0000000000..ec5202d813 --- /dev/null +++ b/src/entity/user/UserDecoration.ts @@ -0,0 +1,21 @@ +import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm'; +import type { User } from './User'; +import type { Decoration } from '../Decoration'; + +@Entity() +export class UserDecoration { + @PrimaryColumn({ type: 'text' }) + userId: string; + + @PrimaryColumn({ type: 'text' }) + decorationId: string; + + @Column({ type: 'timestamp', default: () => 'now()' }) + createdAt: Date; + + @ManyToOne('User', { lazy: true, onDelete: 'CASCADE' }) + user: Promise; + + @ManyToOne('Decoration', { lazy: true, onDelete: 'CASCADE' }) + decoration: Promise; +} diff --git a/src/entity/user/UserTransaction.ts b/src/entity/user/UserTransaction.ts index 196b7d13c9..a1321b9012 100644 --- a/src/entity/user/UserTransaction.ts +++ b/src/entity/user/UserTransaction.ts @@ -52,6 +52,7 @@ export enum UserTransactionType { Post = 'post', Comment = 'comment', BriefGeneration = 'brief_generation', + DecorationPurchase = 'decoration_purchase', } @Entity() diff --git a/src/entity/user/index.ts b/src/entity/user/index.ts index 2a109cf21e..afc5bfdc2d 100644 --- a/src/entity/user/index.ts +++ b/src/entity/user/index.ts @@ -14,3 +14,4 @@ export * from './HotTake'; export * from './UserHotTake'; export * from './UserWorkspacePhoto'; export * from './UserGear'; +export * from './UserDecoration'; diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index 7b1abf5919..6a8a669fd0 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -445,6 +445,13 @@ const obj = new GraphORM({ return link ? extractHandleFromUrl(link.url, 'portfolio') : null; }, }, + activeDecoration: { + relation: { + isMany: false, + childColumn: 'id', + parentColumn: 'activeDecorationId', + }, + }, }, }, UserCompany: { @@ -2314,6 +2321,14 @@ const obj = new GraphORM({ }, }, }, + Decoration: { + requiredColumns: ['id', 'name', 'media'], + fields: { + createdAt: { + transform: transformDate, + }, + }, + }, }); export default obj; diff --git a/src/graphql.ts b/src/graphql.ts index 9d50b273d0..72a134bbf0 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -38,6 +38,7 @@ import * as sourceStack from './schema/sourceStack'; import * as userHotTake from './schema/userHotTake'; import * as gear from './schema/gear'; import * as userWorkspacePhoto from './schema/userWorkspacePhoto'; +import * as decorations from './schema/decorations'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { rateLimitTypeDefs, @@ -92,6 +93,7 @@ export const schema = urlDirective.transformer( userHotTake.typeDefs, gear.typeDefs, userWorkspacePhoto.typeDefs, + decorations.typeDefs, ], resolvers: merge( common.resolvers, @@ -129,6 +131,7 @@ export const schema = urlDirective.transformer( userHotTake.resolvers, gear.resolvers, userWorkspacePhoto.resolvers, + decorations.resolvers, ), }), ), diff --git a/src/migration/1769712470527-AddDecorations.ts b/src/migration/1769712470527-AddDecorations.ts new file mode 100644 index 0000000000..7f783cca58 --- /dev/null +++ b/src/migration/1769712470527-AddDecorations.ts @@ -0,0 +1,85 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDecorations1769712470527 implements MigrationInterface { + name = 'AddDecorations1769712470527'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "decoration" ( + "id" text NOT NULL, + "name" text NOT NULL, + "media" text NOT NULL, + "decorationGroup" text NOT NULL DEFAULT 'subscriber', + "unlockCriteria" text, + "groupOrder" integer NOT NULL DEFAULT 0, + "active" boolean NOT NULL DEFAULT true, + "price" integer, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_decoration_id" PRIMARY KEY ("id") + ) + `); + + await queryRunner.query(` + CREATE TABLE "user_decoration" ( + "userId" text NOT NULL, + "decorationId" text NOT NULL, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_user_decoration" PRIMARY KEY ("userId", "decorationId") + ) + `); + + await queryRunner.query(` + ALTER TABLE "user" + ADD "activeDecorationId" text + `); + + await queryRunner.query(` + ALTER TABLE "user_decoration" + ADD CONSTRAINT "FK_user_decoration_userId" + FOREIGN KEY ("userId") REFERENCES "user"("id") + ON DELETE CASCADE ON UPDATE NO ACTION + `); + + await queryRunner.query(` + ALTER TABLE "user_decoration" + ADD CONSTRAINT "FK_user_decoration_decorationId" + FOREIGN KEY ("decorationId") REFERENCES "decoration"("id") + ON DELETE CASCADE ON UPDATE NO ACTION + `); + + await queryRunner.query(` + ALTER TABLE "user" + ADD CONSTRAINT "FK_user_activeDecorationId" + FOREIGN KEY ("activeDecorationId") REFERENCES "decoration"("id") + ON DELETE SET NULL ON UPDATE NO ACTION + `); + + // Seed initial decorations + await queryRunner.query(` + INSERT INTO "decoration" ("id", "name", "media", "decorationGroup", "unlockCriteria", "groupOrder", "active") + VALUES + ('activesubscriber', 'Active Subscriber', 'https://i.imgur.com/WBkvjLy.png', 'subscriber', 'You have an active daily.dev subscription', 0, true), + ('threemonth', 'Three Month', 'https://i.imgur.com/Q0YJE6i.png', 'subscriber', 'You have been subscribed for 3 months in a row', 1, true), + ('sixmonth', 'Six Month', 'https://i.imgur.com/jdd7OIW.jpeg', 'subscriber', 'You have been subscribed for 6 months in a row', 2, true), + ('oneyear', 'One Year', 'https://i.imgur.com/ArSyAor.jpeg', 'subscriber', 'You have been subscribed for a whole year', 3, true), + ('twoyears', 'Two Years', 'https://i.imgur.com/cPecdk4.jpeg', 'subscriber', 'You have been subscribed for two years', 4, true) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" DROP CONSTRAINT "FK_user_activeDecorationId"`, + ); + await queryRunner.query( + `ALTER TABLE "user_decoration" DROP CONSTRAINT "FK_user_decoration_decorationId"`, + ); + await queryRunner.query( + `ALTER TABLE "user_decoration" DROP CONSTRAINT "FK_user_decoration_userId"`, + ); + await queryRunner.query( + `ALTER TABLE "user" DROP COLUMN "activeDecorationId"`, + ); + await queryRunner.query(`DROP TABLE "user_decoration"`); + await queryRunner.query(`DROP TABLE "decoration"`); + } +} diff --git a/src/routes/boot.ts b/src/routes/boot.ts index 94d05c662a..b92702f425 100644 --- a/src/routes/boot.ts +++ b/src/routes/boot.ts @@ -87,6 +87,7 @@ import { logger } from '../logger'; import { freyjaClient, type FunnelState } from '../integrations/freyja'; import { isUserPartOfOrganization } from '../common/plus'; import { remoteConfig, RemoteConfigValue } from '../remoteConfig'; +import { Decoration } from '../entity/Decoration'; export type BootSquadSource = Omit & { permalink: string; @@ -153,6 +154,7 @@ export type LoggedInBoot = BaseBoot & { coresRole: CoresRole; location?: TLocation | null; profileCompletion?: ProfileCompletion | null; + activeDecoration?: Pick | null; }; accessToken?: AccessToken; marketingCta: MarketingCta | null; @@ -473,6 +475,7 @@ const getUser = async ( 'readme', 'language', 'hideExperience', + 'activeDecorationId', ], }); @@ -522,6 +525,20 @@ const getBalanceBoot: typeof getBalance = async ({ userId }) => { } }; +const getActiveDecoration = async ( + con: DataSource | QueryRunner, + decorationId: string | null, +): Promise | null> => { + if (!decorationId) { + return null; + } + + return con.manager.getRepository(Decoration).findOne({ + where: { id: decorationId }, + select: ['id', 'name', 'media'], + }); +}; + const getLocation = async ( con: DataSource | QueryRunner, userId: string | null, @@ -691,12 +708,19 @@ const loggedInBoot = async ({ getAnonymousTheme(userId), ]); - const profileCompletion = calculateProfileCompletion(user, experienceFlags); - if (!user) { return handleNonExistentUser(con, req, res, middleware); } + const [profileCompletion, activeDecoration] = await Promise.all([ + Promise.resolve(calculateProfileCompletion(user, experienceFlags)), + user.activeDecorationId + ? queryReadReplica(con, ({ queryRunner }) => + getActiveDecoration(queryRunner, user.activeDecorationId), + ) + : Promise.resolve(null), + ]); + // Apply anonymous theme (e.g. recruiter light mode) if user has no saved settings const finalSettings = !settings.updatedAt && anonymousTheme @@ -730,6 +754,7 @@ const loggedInBoot = async ({ 'flags', 'locationId', 'readmeHtml', + 'activeDecorationId', ]), // Legacy social fields with explicit null for JSON backwards compatibility twitter: user.twitter ?? null, @@ -768,6 +793,7 @@ const loggedInBoot = async ({ hasLocationSet, location, profileCompletion, + activeDecoration, }, visit, alerts: { diff --git a/src/schema/decorations.ts b/src/schema/decorations.ts new file mode 100644 index 0000000000..66f56a4032 --- /dev/null +++ b/src/schema/decorations.ts @@ -0,0 +1,315 @@ +import { IResolvers } from '@graphql-tools/utils'; +import { AuthContext } from '../Context'; +import { traceResolvers } from './trace'; +import { Decoration, User, UserDecoration } from '../entity'; +import graphorm from '../graphorm'; +import { GraphQLResolveInfo } from 'graphql'; +import { ForbiddenError, ValidationError } from 'apollo-server-errors'; +import { ConflictError, TransferError } from '../errors'; +import { + UserTransaction, + UserTransactionProcessor, + UserTransactionStatus, + UserTransactionType, +} from '../entity/user/UserTransaction'; +import { + getBalance, + transferCores, + throwUserTransactionError, + type GetBalanceResult, +} from '../common/njord'; +import { checkUserCoresAccess } from '../common/user'; +import { parseBigInt, systemUser } from '../common/utils'; +import { CoresRole } from '../types'; +import { randomUUID } from 'node:crypto'; + +export interface GQLDecoration { + id: string; + name: string; + media: string; + decorationGroup: string; + unlockCriteria: string | null; + price: number | null; + isUnlocked: boolean; + isPurchasable: boolean; +} + +export interface GQLPurchaseDecorationResult { + decoration: GQLDecoration; + balance: GetBalanceResult; +} + +export interface GQLDecorationGroup { + group: string; + label: string; + decorations: GQLDecoration[]; +} + +const DECORATION_GROUP_LABELS: Record = { + subscriber: 'Plus Member', +}; + +export const typeDefs = /* GraphQL */ ` + type Decoration { + id: ID! + name: String! + media: String! + decorationGroup: String! + unlockCriteria: String + price: Int + isUnlocked: Boolean! + isPurchasable: Boolean! + } + + type DecorationGroup { + group: String! + label: String! + decorations: [Decoration!]! + } + + type PurchaseDecorationResult { + decoration: Decoration! + balance: Int! + } + + extend type User { + activeDecoration: Decoration + } + + extend type Query { + decorationsByGroup: [DecorationGroup!]! @auth + } + + extend type Mutation { + setActiveDecoration(decorationId: ID): User @auth + purchaseDecoration(decorationId: ID!): PurchaseDecorationResult! @auth + } +`; + +export const resolvers: IResolvers = traceResolvers< + unknown, + AuthContext +>({ + Query: { + decorationsByGroup: async (_, __, ctx): Promise => { + const decorations = await ctx.con.getRepository(Decoration).find({ + where: { active: true }, + order: { decorationGroup: 'ASC', groupOrder: 'ASC' }, + }); + + const userDecorations = await ctx.con.getRepository(UserDecoration).find({ + where: { userId: ctx.userId }, + select: ['decorationId'], + }); + const userDecorationIds = new Set( + userDecorations.map((ud) => ud.decorationId), + ); + + const groupedDecorations = new Map(); + + for (const decoration of decorations) { + const isUnlocked = userDecorationIds.has(decoration.id); + const isPurchasable = + decoration.price !== null && decoration.price > 0 && !isUnlocked; + + const gqlDecoration: GQLDecoration = { + id: decoration.id, + name: decoration.name, + media: decoration.media, + decorationGroup: decoration.decorationGroup, + unlockCriteria: decoration.unlockCriteria, + price: decoration.price, + isUnlocked, + isPurchasable, + }; + + const group = decoration.decorationGroup; + if (!groupedDecorations.has(group)) { + groupedDecorations.set(group, []); + } + groupedDecorations.get(group)!.push(gqlDecoration); + } + + return Array.from(groupedDecorations.entries()).map( + ([group, decorations]) => ({ + group, + label: DECORATION_GROUP_LABELS[group] || group, + decorations, + }), + ); + }, + }, + Mutation: { + setActiveDecoration: async ( + _, + { decorationId }: { decorationId: string | null }, + ctx, + info: GraphQLResolveInfo, + ): Promise => { + if (decorationId === null || decorationId === undefined) { + await ctx.con + .getRepository(User) + .update({ id: ctx.userId }, { activeDecorationId: null }); + + return graphorm.queryOneOrFail(ctx, info, (builder) => ({ + ...builder, + queryBuilder: builder.queryBuilder.where( + `"${builder.alias}"."id" = :id`, + { id: ctx.userId }, + ), + })); + } + + const decoration = await ctx.con.getRepository(Decoration).findOneBy({ + id: decorationId, + active: true, + }); + + if (!decoration) { + throw new ValidationError('Decoration not found'); + } + + const userDecoration = await ctx.con + .getRepository(UserDecoration) + .findOneBy({ + userId: ctx.userId, + decorationId, + }); + + if (!userDecoration) { + throw new ForbiddenError('Decoration is not unlocked'); + } + + await ctx.con + .getRepository(User) + .update({ id: ctx.userId }, { activeDecorationId: decorationId }); + + return graphorm.queryOneOrFail(ctx, info, (builder) => ({ + ...builder, + queryBuilder: builder.queryBuilder.where( + `"${builder.alias}"."id" = :id`, + { id: ctx.userId }, + ), + })); + }, + purchaseDecoration: async ( + _, + { decorationId }: { decorationId: string }, + ctx, + ): Promise => { + const { userId } = ctx; + + const user = await ctx.con + .getRepository(User) + .findOneByOrFail({ id: userId }); + + if ( + !checkUserCoresAccess({ + user, + requiredRole: CoresRole.User, + }) + ) { + throw new ForbiddenError('You do not have access to Cores'); + } + + const decoration = await ctx.con.getRepository(Decoration).findOneBy({ + id: decorationId, + active: true, + }); + + if (!decoration) { + throw new ValidationError('Decoration not found'); + } + + if (decoration.price === null || decoration.price <= 0) { + throw new ValidationError( + 'This decoration is not available for purchase', + ); + } + + const existingOwnership = await ctx.con + .getRepository(UserDecoration) + .findOneBy({ + userId, + decorationId, + }); + + if (existingOwnership) { + throw new ConflictError('You already own this decoration'); + } + + const userBalance = await getBalance({ userId }); + if (userBalance.amount < decoration.price) { + throw new ConflictError('Not enough Cores to purchase this decoration'); + } + + const { transfer } = await ctx.con.transaction(async (entityManager) => { + const userTransaction = await entityManager + .getRepository(UserTransaction) + .save( + entityManager.getRepository(UserTransaction).create({ + id: randomUUID(), + processor: UserTransactionProcessor.Njord, + receiverId: systemUser.id, + status: UserTransactionStatus.Success, + productId: null, + senderId: userId, + value: decoration.price!, + valueIncFees: decoration.price!, + fee: 0, + request: ctx.requestMeta, + referenceType: UserTransactionType.DecorationPurchase, + referenceId: decorationId, + flags: { + note: `Decoration purchase: ${decoration.name}`, + }, + }), + ); + + try { + await entityManager.getRepository(UserDecoration).insert({ + userId, + decorationId, + }); + + const transfer = await transferCores({ + ctx, + transaction: userTransaction, + entityManager, + }); + + return { transfer }; + } catch (error) { + if (error instanceof TransferError) { + await throwUserTransactionError({ + ctx, + entityManager, + error, + transaction: userTransaction, + }); + } + + throw error; + } + }); + + const newBalance = parseBigInt(transfer.senderBalance!.newBalance); + + return { + decoration: { + id: decoration.id, + name: decoration.name, + media: decoration.media, + decorationGroup: decoration.decorationGroup, + unlockCriteria: decoration.unlockCriteria, + price: decoration.price, + isUnlocked: true, + isPurchasable: false, + }, + balance: { + amount: newBalance, + }, + }; + }, + }, +}); diff --git a/src/workers/index.ts b/src/workers/index.ts index f88fda545a..95d1b607c5 100644 --- a/src/workers/index.ts +++ b/src/workers/index.ts @@ -54,6 +54,7 @@ import { postAddedSlackChannelSendWorker } from './postAddedSlackChannelSend'; import userCompanyApprovedCio from './userCompanyApprovedCio'; import userUpdatedPlusSubscriptionSquad from './userUpdatedPlusSubscriptionSquad'; import userUpdatedPlusSubscriptionCustomFeed from './userUpdatedPlusSubscriptionCustomFeed'; +import userUpdatedPlusSubscriptionDecoration from './userUpdatedPlusSubscriptionDecoration'; import { postTranslated } from './postTranslated'; import postDeletedSharedPostCleanup from './postDeletedSharedPostCleanup'; import { transactionBalanceLogWorker } from './transactionBalanceLog'; @@ -136,6 +137,7 @@ export const typedWorkers: BaseTypedWorker[] = [ userCompanyApprovedCio, userUpdatedPlusSubscriptionSquad, userUpdatedPlusSubscriptionCustomFeed, + userUpdatedPlusSubscriptionDecoration, postTranslated, transactionBalanceLogWorker, userBoughtCores, diff --git a/src/workers/userUpdatedPlusSubscriptionDecoration.ts b/src/workers/userUpdatedPlusSubscriptionDecoration.ts new file mode 100644 index 0000000000..d057452cb3 --- /dev/null +++ b/src/workers/userUpdatedPlusSubscriptionDecoration.ts @@ -0,0 +1,84 @@ +import { TypedWorker } from './worker'; +import { Decoration, User, UserDecoration } from '../entity'; +import { hasPlusStatusChanged } from '../paddle'; +import { queryReadReplica } from '../common/queryReadReplica'; +import { ghostUser } from '../common'; +import { In } from 'typeorm'; + +const worker: TypedWorker<'user-updated'> = { + subscription: 'api.user-updated-plus-subscription-decoration', + handler: async (message, con, log) => { + const { + data: { newProfile: user, user: oldUser }, + } = message; + + const beforeFlags = JSON.parse( + (oldUser.subscriptionFlags as string) || '{}', + ) as User['subscriptionFlags']; + const afterFlags = JSON.parse( + (user.subscriptionFlags as string) || '{}', + ) as User['subscriptionFlags']; + + if (user.id === ghostUser.id || !user.infoConfirmed) { + return; + } + + const { isPlus, wasPlus, statusChanged } = hasPlusStatusChanged( + afterFlags, + beforeFlags, + ); + + if (!statusChanged || isPlus || !wasPlus) { + return; + } + + // Get all subscriber decorations + const subscriberDecorations = await queryReadReplica( + con, + ({ queryRunner }) => + queryRunner.manager.getRepository(Decoration).find({ + where: { decorationGroup: 'subscriber' }, + select: ['id'], + }), + ); + + const subscriberDecorationIds = subscriberDecorations.map((d) => d.id); + + if (subscriberDecorationIds.length === 0) { + return; + } + + // Remove all subscriber decorations from user + const { affected: deletedCount } = await con + .getRepository(UserDecoration) + .delete({ + userId: user.id, + decorationId: In(subscriberDecorationIds), + }); + + // Clear activeDecorationId if it was a subscriber decoration + if ( + user.activeDecorationId && + subscriberDecorationIds.includes(user.activeDecorationId) + ) { + await con + .getRepository(User) + .update({ id: user.id }, { activeDecorationId: null }); + } + + const hadActiveSubscriberDecoration = + user.activeDecorationId && + subscriberDecorationIds.includes(user.activeDecorationId); + + log.info( + { + userId: user.id, + deletedDecorations: deletedCount, + clearedActiveDecoration: hadActiveSubscriberDecoration, + }, + 'cleared subscriber decorations after Plus expiry', + ); + }, +}; + +export default worker;