From 2c6bbef22bb38e954b788b52b15709164d1e0c03 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Thu, 16 Jul 2026 20:02:31 +0200 Subject: [PATCH 1/6] feat(streak-freeze): add entities, migrations, seeds, and purchase/query GraphQL surface Adds the data model and GraphQL surface for automated streak freeze: a freezesAvailable counter on user_streak, a streak_freeze Product type (seeded with 3 placeholder-priced packs), a freeze UserStreakAction type, and an optOutStreakFreeze setting. Exposes streakFreezeProducts, userStreakFreezeDates, and purchaseStreakFreeze (Cores purchase with a 5-freeze stockpile cap) following the existing recoverStreak/award patterns. Co-Authored-By: Claude Fable 5 --- __tests__/__snapshots__/settings.ts.snap | 4 + __tests__/settings.ts | 22 ++ __tests__/users.ts | 251 ++++++++++++++++++ seeds/Product.json | 36 +++ src/entity/Product.ts | 4 +- src/entity/Settings.ts | 4 + src/entity/user/UserStreak.ts | 3 + src/entity/user/UserStreakAction.ts | 1 + ...1327090-AddFreezesAvailableToUserStreak.ts | 21 ++ ...1328090-AddOptOutStreakFreezeToSettings.ts | 21 ++ src/schema/njord.ts | 1 + src/schema/settings.ts | 12 + src/schema/users.ts | 208 +++++++++++++++ 13 files changed, 587 insertions(+), 1 deletion(-) create mode 100644 src/migration/1784211327090-AddFreezesAvailableToUserStreak.ts create mode 100644 src/migration/1784211328090-AddOptOutStreakFreezeToSettings.ts diff --git a/__tests__/__snapshots__/settings.ts.snap b/__tests__/__snapshots__/settings.ts.snap index a4cf0a740c..6a5eeaf916 100644 --- a/__tests__/__snapshots__/settings.ts.snap +++ b/__tests__/__snapshots__/settings.ts.snap @@ -25,6 +25,7 @@ Object { "optOutLevelSystem": false, "optOutQuestSystem": false, "optOutReadingStreak": false, + "optOutStreakFreeze": false, "optOutWeeklyGoal": false, "showOnlyUnreadPosts": false, "showTopSites": true, @@ -63,6 +64,7 @@ Object { "optOutLevelSystem": false, "optOutQuestSystem": false, "optOutReadingStreak": false, + "optOutStreakFreeze": false, "optOutWeeklyGoal": false, "showOnlyUnreadPosts": false, "showTopSites": true, @@ -103,6 +105,7 @@ Object { "optOutLevelSystem": false, "optOutQuestSystem": false, "optOutReadingStreak": false, + "optOutStreakFreeze": false, "optOutWeeklyGoal": false, "showOnlyUnreadPosts": false, "showTopSites": true, @@ -143,6 +146,7 @@ Object { "optOutLevelSystem": false, "optOutQuestSystem": false, "optOutReadingStreak": false, + "optOutStreakFreeze": false, "optOutWeeklyGoal": false, "showOnlyUnreadPosts": false, "showTopSites": true, diff --git a/__tests__/settings.ts b/__tests__/settings.ts index 8119268218..53cd629a3b 100644 --- a/__tests__/settings.ts +++ b/__tests__/settings.ts @@ -108,6 +108,7 @@ describe('mutation updateUserSettings', () => { customLinks optOutWeeklyGoal optOutReadingStreak + optOutStreakFreeze optOutLevelSystem optOutQuestSystem optOutCompanion @@ -433,6 +434,27 @@ describe('mutation updateUserSettings', () => { expect(updated.optOutQuestSystem).toBe(true); }); + it('should update optOutStreakFreeze', async () => { + loggedUser = '1'; + + const repo = con.getRepository(Settings); + await repo.save( + repo.create({ + userId: '1', + optOutStreakFreeze: false, + }), + ); + + const res = await client.mutate(MUTATION, { + variables: { data: { optOutStreakFreeze: true } }, + }); + + expect(res.data.updateUserSettings.optOutStreakFreeze).toBe(true); + + const updated = await repo.findOneByOrFail({ userId: '1' }); + expect(updated.optOutStreakFreeze).toBe(true); + }); + it('should update campaignCtaPlacement', async () => { loggedUser = '1'; diff --git a/__tests__/users.ts b/__tests__/users.ts index 48269b0544..5f3dd93d7e 100644 --- a/__tests__/users.ts +++ b/__tests__/users.ts @@ -63,6 +63,7 @@ import { PostType, UNKNOWN_SOURCE, } from '../src/entity'; +import { Product, ProductType } from '../src/entity/Product'; import { UserProfileAnalytics } from '../src/entity/user/UserProfileAnalytics'; import { UserProfileAnalyticsHistory } from '../src/entity/user/UserProfileAnalyticsHistory'; import { PostAnalyticsHistory } from '../src/entity/posts/PostAnalyticsHistory'; @@ -74,6 +75,7 @@ import { getTimezonedStartOfISOWeek, ghostUser, type GQLUserTopReader, + MAX_STREAK_FREEZES, portfolioLimit, sendEmail, systemUser, @@ -1748,6 +1750,255 @@ describe('streak recovery mutation', () => { }); }); +describe('streakFreezeProducts query', () => { + const QUERY = ` + query { + streakFreezeProducts { + id + name + value + flags { + quantity + } + } + } + `; + + beforeEach(async () => { + await saveFixtures(con, Product, [ + { + id: '11111111-1111-1111-1111-111111111111', + name: 'Streak freeze 3-pack', + image: 'https://daily.dev/freeze.jpg', + type: ProductType.StreakFreeze, + value: 80, + flags: { quantity: 3 }, + }, + { + id: '22222222-2222-2222-2222-222222222222', + name: 'Streak freeze', + image: 'https://daily.dev/freeze.jpg', + type: ProductType.StreakFreeze, + value: 30, + flags: { quantity: 1 }, + }, + { + id: '33333333-3333-3333-3333-333333333333', + name: 'Some award', + image: 'https://daily.dev/award.jpg', + type: ProductType.Award, + value: 10, + }, + ]); + }); + + it('should return only streak freeze products ordered by value ascending', async () => { + const res = await client.query(QUERY); + + expect(res.errors).toBeFalsy(); + expect(res.data.streakFreezeProducts).toMatchObject([ + { + id: '22222222-2222-2222-2222-222222222222', + name: 'Streak freeze', + value: 30, + flags: { quantity: 1 }, + }, + { + id: '11111111-1111-1111-1111-111111111111', + name: 'Streak freeze 3-pack', + value: 80, + flags: { quantity: 3 }, + }, + ]); + }); +}); + +describe('userStreakFreezeDates query', () => { + const QUERY = ` + query UserStreakFreezeDates($limit: Int) { + userStreakFreezeDates(limit: $limit) + } + `; + + beforeEach(async () => { + await con.getRepository(UserStreakAction).save([ + { + userId: '1', + type: UserStreakActionType.Freeze, + createdAt: new Date('2024-06-24'), + }, + { + userId: '1', + type: UserStreakActionType.Freeze, + createdAt: new Date('2024-06-25'), + }, + ]); + }); + + it('should not authorize when not logged in', async () => + await testQueryErrorCode(client, { query: QUERY }, 'UNAUTHENTICATED')); + + it('should return the dates of consumed freezes, newest first', async () => { + loggedUser = '1'; + + const res = await client.query(QUERY); + + expect(res.errors).toBeFalsy(); + expect(res.data.userStreakFreezeDates).toEqual([ + new Date('2024-06-25').toISOString(), + new Date('2024-06-24').toISOString(), + ]); + }); +}); + +describe('purchaseStreakFreeze mutation', () => { + const MUTATION = ` + mutation PurchaseStreakFreeze($productId: ID!) { + purchaseStreakFreeze(productId: $productId) { + freezesAvailable + transactionId + balance { + amount + } + } + } + `; + + const productId = '44444444-4444-4444-4444-444444444444'; + const awardProductId = '55555555-5555-5555-5555-555555555555'; + + beforeEach(async () => { + const mockTransport = createMockNjordTransport(); + + jest + .spyOn(njordCommon, 'getNjordClient') + .mockImplementation(() => createClient(Credits, mockTransport)); + + await ioRedisPool.execute((client) => client.flushall()); + + await saveFixtures( + con, + User, + usersFixture.map((item) => ({ + ...item, + id: `${item.id}-psf`, + username: `${item.username}-psf`, + coresRole: CoresRole.User, + })), + ); + + await saveFixtures(con, Product, [ + { + id: productId, + name: 'Streak freeze 3-pack', + image: 'https://daily.dev/freeze.jpg', + type: ProductType.StreakFreeze, + value: 80, + flags: { quantity: 3 }, + }, + { + id: awardProductId, + name: 'Some award', + image: 'https://daily.dev/award.jpg', + type: ProductType.Award, + value: 80, + }, + ]); + }); + + it('should not authorize when not logged in', async () => + await testMutationErrorCode( + client, + { mutation: MUTATION, variables: { productId } }, + 'UNAUTHENTICATED', + )); + + it('should throw when the product is not a streak freeze product', async () => { + loggedUser = '1-psf'; + + await testMutationErrorCode( + client, + { mutation: MUTATION, variables: { productId: awardProductId } }, + 'GRAPHQL_VALIDATION_FAILED', + ); + }); + + it('should throw when the user does not have enough Cores', async () => { + loggedUser = '1-psf'; + + await testMutationErrorCode( + client, + { mutation: MUTATION, variables: { productId } }, + 'CONFLICT', + ); + }); + + it('should throw when the purchase would exceed the freeze cap', async () => { + loggedUser = '1-psf'; + + const testNjordClient = njordCommon.getNjordClient(); + await testNjordClient.transfer({ + idempotencyKey: randomUUID(), + transfers: [ + { + sender: { id: systemUser.id, type: EntityType.SYSTEM }, + receiver: { id: loggedUser, type: EntityType.USER }, + amount: 1000, + }, + ], + }); + + await con + .getRepository(UserStreak) + .save({ userId: loggedUser, freezesAvailable: MAX_STREAK_FREEZES - 1 }); + + await testMutationErrorCode( + client, + { mutation: MUTATION, variables: { productId } }, + 'CONFLICT', + ); + }); + + it('should purchase a streak freeze pack', async () => { + loggedUser = '1-psf'; + + const testNjordClient = njordCommon.getNjordClient(); + await testNjordClient.transfer({ + idempotencyKey: randomUUID(), + transfers: [ + { + sender: { id: systemUser.id, type: EntityType.SYSTEM }, + receiver: { id: loggedUser, type: EntityType.USER }, + amount: 100, + }, + ], + }); + + const res = await client.mutate(MUTATION, { variables: { productId } }); + + expect(res.errors).toBeFalsy(); + expect(res.data.purchaseStreakFreeze).toMatchObject({ + freezesAvailable: 3, + transactionId: expect.any(String), + balance: { amount: 20 }, + }); + + const streak = await con + .getRepository(UserStreak) + .findOneByOrFail({ userId: loggedUser }); + expect(streak.freezesAvailable).toBe(3); + + const { transactionId } = res.data.purchaseStreakFreeze; + const transaction = await con + .getRepository(UserTransaction) + .findOneByOrFail({ id: transactionId }); + expect(transaction.senderId).toEqual(loggedUser); + expect(transaction.receiverId).toEqual(systemUser.id); + expect(transaction.productId).toEqual(productId); + expect(transaction.value).toEqual(80); + }); +}); + describe('mutation updateStreakConfig', () => { const QUERY = `mutation UpdateStreakConfig($weekStart: Int) { updateStreakConfig(weekStart: $weekStart) { diff --git a/seeds/Product.json b/seeds/Product.json index f408175529..f0cf9a3011 100644 --- a/seeds/Product.json +++ b/seeds/Product.json @@ -155,5 +155,41 @@ "imageGlow": "https://media.daily.dev/image/upload/s--9SAk6IKY--/f_auto,q_auto/v1783344582/public/Color%3Dglow", "restricted": true } + }, + { + "id": "6f1e1a2f-3f3a-4f2f-9c1e-8b1f6ef34a01", + "type": "streak_freeze", + "image": "https://media.daily.dev/image/upload/s--pS3eG3vC--/f_auto/v1784211327/public/streakFreeze1", + "name": "Streak freeze", + "createdAt": "2026-07-16T00:00:00.000Z", + "updatedAt": "2026-07-16T00:00:00.000Z", + "value": 30, + "flags": { + "quantity": 1 + } + }, + { + "id": "6f1e1a2f-3f3a-4f2f-9c1e-8b1f6ef34a02", + "type": "streak_freeze", + "image": "https://media.daily.dev/image/upload/s--pS3eG3vC--/f_auto/v1784211327/public/streakFreeze3", + "name": "Streak freeze 3-pack", + "createdAt": "2026-07-16T00:00:00.000Z", + "updatedAt": "2026-07-16T00:00:00.000Z", + "value": 80, + "flags": { + "quantity": 3 + } + }, + { + "id": "6f1e1a2f-3f3a-4f2f-9c1e-8b1f6ef34a03", + "type": "streak_freeze", + "image": "https://media.daily.dev/image/upload/s--pS3eG3vC--/f_auto/v1784211327/public/streakFreeze5", + "name": "Streak freeze 5-pack", + "createdAt": "2026-07-16T00:00:00.000Z", + "updatedAt": "2026-07-16T00:00:00.000Z", + "value": 120, + "flags": { + "quantity": 5 + } } ] diff --git a/src/entity/Product.ts b/src/entity/Product.ts index 8d3237ba9e..45fca7b113 100644 --- a/src/entity/Product.ts +++ b/src/entity/Product.ts @@ -11,15 +11,17 @@ export type ProductFlags = Partial<{ description: string; imageGlow: string; restricted: boolean; + quantity: number; }>; export type ProductFlagsPublic = Pick< ProductFlags, - 'description' | 'imageGlow' + 'description' | 'imageGlow' | 'quantity' >; export enum ProductType { Award = 'award', + StreakFreeze = 'streak_freeze', } @Entity() diff --git a/src/entity/Settings.ts b/src/entity/Settings.ts index 58f51d5a5d..9c4795c685 100644 --- a/src/entity/Settings.ts +++ b/src/entity/Settings.ts @@ -138,6 +138,9 @@ export class Settings { @Column({ default: false }) optOutReadingStreak: boolean; + @Column({ default: false }) + optOutStreakFreeze: boolean; + @Column({ default: false }) optOutLevelSystem: boolean; @@ -195,6 +198,7 @@ export const SETTINGS_DEFAULT = { optOutCompanion: false, optOutWeeklyGoal: false, optOutReadingStreak: false, + optOutStreakFreeze: false, optOutLevelSystem: false, optOutQuestSystem: false, optOutAchievements: false, diff --git a/src/entity/user/UserStreak.ts b/src/entity/user/UserStreak.ts index 85068fb74d..f149918132 100644 --- a/src/entity/user/UserStreak.ts +++ b/src/entity/user/UserStreak.ts @@ -31,6 +31,9 @@ export class UserStreak { @Column({ type: 'integer', default: 0 }) maxStreak: number; + @Column({ type: 'integer', default: 0 }) + freezesAvailable: number; + @Column({ type: 'timestamptz', default: null }) lastViewAt: Date | null; diff --git a/src/entity/user/UserStreakAction.ts b/src/entity/user/UserStreakAction.ts index d84a67ee78..11750917ca 100644 --- a/src/entity/user/UserStreakAction.ts +++ b/src/entity/user/UserStreakAction.ts @@ -3,6 +3,7 @@ import type { User } from './User'; export enum UserStreakActionType { Recover = 'recover', + Freeze = 'freeze', } @Entity() diff --git a/src/migration/1784211327090-AddFreezesAvailableToUserStreak.ts b/src/migration/1784211327090-AddFreezesAvailableToUserStreak.ts new file mode 100644 index 0000000000..e756864196 --- /dev/null +++ b/src/migration/1784211327090-AddFreezesAvailableToUserStreak.ts @@ -0,0 +1,21 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddFreezesAvailableToUserStreak1784211327090 + implements MigrationInterface +{ + name = 'AddFreezesAvailableToUserStreak1784211327090'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(/* sql */ ` + ALTER TABLE "user_streak" + ADD "freezesAvailable" integer NOT NULL DEFAULT 0 + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(/* sql */ ` + ALTER TABLE "user_streak" + DROP COLUMN "freezesAvailable" + `); + } +} diff --git a/src/migration/1784211328090-AddOptOutStreakFreezeToSettings.ts b/src/migration/1784211328090-AddOptOutStreakFreezeToSettings.ts new file mode 100644 index 0000000000..0de8cb0665 --- /dev/null +++ b/src/migration/1784211328090-AddOptOutStreakFreezeToSettings.ts @@ -0,0 +1,21 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddOptOutStreakFreezeToSettings1784211328090 + implements MigrationInterface +{ + name = 'AddOptOutStreakFreezeToSettings1784211328090'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(/* sql */ ` + ALTER TABLE "settings" + ADD "optOutStreakFreeze" boolean NOT NULL DEFAULT false + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(/* sql */ ` + ALTER TABLE "settings" + DROP COLUMN "optOutStreakFreeze" + `); + } +} diff --git a/src/schema/njord.ts b/src/schema/njord.ts index b827231b62..3b9958f688 100644 --- a/src/schema/njord.ts +++ b/src/schema/njord.ts @@ -93,6 +93,7 @@ export const typeDefs = /* GraphQL */ ` type ProductFlagsPublic { description: String imageGlow: String + quantity: Int } type Product { diff --git a/src/schema/settings.ts b/src/schema/settings.ts index b6d299cae6..42fe15c07d 100644 --- a/src/schema/settings.ts +++ b/src/schema/settings.ts @@ -32,6 +32,7 @@ interface GQLSettings { autoDismissNotifications: boolean; updatedAt: Date; optOutReadingStreak: boolean; + optOutStreakFreeze: boolean; optOutLevelSystem: boolean; optOutQuestSystem: boolean; optOutAchievements: boolean; @@ -59,6 +60,7 @@ interface GQLUpdateSettingsInput extends Partial { campaignCtaPlacement?: CampaignCtaPlacement; customLinks?: string[]; optOutReadingStreak?: boolean; + optOutStreakFreeze?: boolean; optOutLevelSystem?: boolean; optOutQuestSystem?: boolean; optOutAchievements?: boolean; @@ -197,6 +199,11 @@ export const typeDefs = /* GraphQL */ ` """ optOutReadingStreak: Boolean! + """ + Whether the user opted out from automated streak freeze + """ + optOutStreakFreeze: Boolean! + """ Whether the user opted out from the level system """ @@ -330,6 +337,11 @@ export const typeDefs = /* GraphQL */ ` """ optOutReadingStreak: Boolean + """ + Whether the user opted out from automated streak freeze + """ + optOutStreakFreeze: Boolean + """ Whether the user opted out from the level system """ diff --git a/src/schema/users.ts b/src/schema/users.ts index 23e4f92b64..0d5dd87584 100644 --- a/src/schema/users.ts +++ b/src/schema/users.ts @@ -78,6 +78,7 @@ import { DayOfWeek, getBufferFromStream, getInviteLink, + getLimit, getShortUrl, getUserPermalink, getUserReadingRank, @@ -87,6 +88,7 @@ import { GQLUserStreakTz, type GQLUserTopReader, mapCloudinaryUrl, + MAX_STREAK_FREEZES, parseBigInt, resubscribeUser, sendEmail, @@ -179,11 +181,14 @@ import { insertOrIgnoreAction } from './actions'; import { getGeo } from '../common/geo'; import { validateVordrWords } from '../common/vordr'; import { + createTransaction, getBalance, throwUserTransactionError, type TransactionCreated, transferCores, } from '../common/njord'; +import { Product, ProductType } from '../entity/Product'; +import type { GQLProduct } from './njord'; import { UserTransaction, UserTransactionProcessor, @@ -1087,6 +1092,31 @@ export const typeDefs = /* GraphQL */ ` weekStart: Int balance: UserBalance + + """ + Number of streak freezes the user currently has available + """ + freezesAvailable: Int! + } + + """ + Result of purchasing a streak freeze pack + """ + type PurchaseStreakFreezeResult { + """ + Number of streak freezes the user has available after the purchase + """ + freezesAvailable: Int! + + """ + Balance of the user after the purchase + """ + balance: UserBalance! + + """ + Id of the purchase transaction + """ + transactionId: ID! } ${toGQLEnum(UserPersonalizedDigestType, 'DigestType')} @@ -1347,6 +1377,14 @@ export const typeDefs = /* GraphQL */ ` """ streakRecover: StreakRecoverQuery @auth """ + Get the list of purchasable streak freeze products, ordered by value ascending + """ + streakFreezeProducts: [Product!]! + """ + Get the dates (newest first) the user's streak was protected by a streak freeze + """ + userStreakFreezeDates(limit: Int = 30): [DateTime!]! @auth + """ Get top creators for a tag """ topCreatorsByTag(tag: String!, limit: Int): [User!]! @@ -1769,6 +1807,16 @@ export const typeDefs = /* GraphQL */ ` cores: Boolean ): UserStreak @auth + """ + Purchase a streak freeze pack with Cores + """ + purchaseStreakFreeze( + """ + Id of the streak freeze product to purchase + """ + productId: ID! + ): PurchaseStreakFreezeResult! @auth + """ Request an app account token that is used for StoreKit """ @@ -2124,10 +2172,16 @@ const getUserStreakQuery = async ( .addSelect('u.id', 'userId') .addSelect('u.timezone', 'timezone') .addSelect('u."weekStart"', 'weekStart') + .addSelect('u."coresRole"', 'coresRole') .addSelect( 'COALESCE(s."optOutReadingStreak", false)', 'optOutReadingStreak', ) + .addSelect( + 'COALESCE(s."optOutStreakFreeze", false)', + 'optOutStreakFreeze', + ) + .addSelect(`"${builder.alias}"."freezesAvailable"`, 'freezesAvailable') .innerJoin(User, 'u', `"${builder.alias}"."userId" = u.id`) .leftJoin(Settings, 's', 's."userId" = u.id') .where(`"${builder.alias}"."userId" = :id`, { @@ -2737,6 +2791,51 @@ export const resolvers: IResolvers = { regularCost: StreakRestoreCoresPrice.Regular, }; }, + streakFreezeProducts: async ( + _, + __, + ctx: Context, + info, + ): Promise => { + return graphorm.query( + ctx, + info, + (builder) => { + builder.queryBuilder = builder.queryBuilder + .andWhere(`${builder.alias}.type = :type`, { + type: ProductType.StreakFreeze, + }) + .andWhere( + `(${builder.alias}.flags->>'restricted') IS DISTINCT FROM 'true'`, + ) + .orderBy(`${builder.alias}."value"`, 'ASC'); + + return builder; + }, + true, + ); + }, + userStreakFreezeDates: async ( + _, + { limit }: { limit: number }, + ctx: AuthContext, + ): Promise => { + const take = getLimit({ limit, defaultLimit: 30, max: 100 }); + + const actions = await queryReadReplica(ctx.con, ({ queryRunner }) => + queryRunner.manager.getRepository(UserStreakAction).find({ + select: ['createdAt'], + where: { + userId: ctx.userId, + type: UserStreakActionType.Freeze, + }, + order: { createdAt: 'DESC' }, + take, + }), + ); + + return actions.map(({ createdAt }) => createdAt); + }, userReads: async (): Promise => { // Kept for backwards compatability return 0; @@ -4228,6 +4327,115 @@ export const resolvers: IResolvers = { }, }; }, + purchaseStreakFreeze: async ( + _, + { productId }: { productId: string }, + ctx: AuthContext, + ): Promise<{ + freezesAvailable: number; + balance: { amount: number }; + transactionId: string; + }> => { + const { userId } = ctx; + + const [product, user, userBalance] = await Promise.all([ + ctx.con.getRepository(Product).findOneBy({ id: productId }), + ctx.con.getRepository(User).findOneByOrFail({ id: userId }), + getBalance(ctx), + ]); + + if (!product || product.type !== ProductType.StreakFreeze) { + throw new ValidationError('Invalid streak freeze product'); + } + + if ( + !checkUserCoresAccess({ + user, + requiredRole: CoresRole.User, + }) + ) { + throw new ForbiddenError('You do not have access to Cores'); + } + + if (userBalance.amount < product.value) { + throw new ConflictError('Not enough Cores to purchase streak freeze'); + } + + const quantity = product.flags?.quantity || 1; + + const { transaction, transfer, freezesAvailable } = + await ctx.con.transaction(async (entityManager) => { + await entityManager + .createQueryBuilder() + .insert() + .into(UserStreak) + .values({ userId }) + .orIgnore() + .execute(); + + const updateResult = await entityManager + .createQueryBuilder() + .update(UserStreak) + .set({ + freezesAvailable: () => `"freezesAvailable" + :quantity`, + updatedAt: new Date(), + }) + .where('"userId" = :userId', { userId }) + .andWhere('"freezesAvailable" + :quantity <= :max', { + quantity, + max: MAX_STREAK_FREEZES, + }) + .returning(['freezesAvailable']) + .execute(); + + if (!updateResult.affected) { + throw new ConflictError( + `You can hold at most ${MAX_STREAK_FREEZES} streak freezes`, + ); + } + + const transaction = await createTransaction({ + ctx, + entityManager, + productId: product.id, + receiverId: systemUser.id, + note: 'Streak freeze purchase', + }); + + try { + const transfer = await transferCores({ + ctx, + transaction, + entityManager, + }); + + return { + transaction, + transfer, + freezesAvailable: updateResult.raw[0].freezesAvailable as number, + }; + } catch (error) { + if (error instanceof TransferError) { + await throwUserTransactionError({ + ctx, + entityManager, + error, + transaction, + }); + } + + throw error; + } + }); + + return { + freezesAvailable, + balance: { + amount: parseBigInt(transfer.senderBalance!.newBalance), + }, + transactionId: transaction.id, + }; + }, sendReport: async ( _, { type, ...args }: SendReportArgs, From 29332724e4b3c6a13bb46c066b6d0689287ae541 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Thu, 16 Jul 2026 20:04:51 +0200 Subject: [PATCH 2/6] feat(streak-freeze): auto-consume freezes instead of resetting streaks Wires freeze consumption into both reset paths (the hourly cron and the lazy checkAndClearUserStreak read path) so behavior is identical: when a reset would occur, compute the missed days beyond the existing grace period and, if the user's balance covers all of them (has Cores access, hasn't opted out), consume one freeze per missed day and emit one api.v1.user-streak-updated event per frozen day instead of clearing the streak. Otherwise the existing reset-and-recover flow is unchanged. The gap calculation now folds in the most recent freeze action (in addition to the existing recover action) so a freeze consumed in one cron run advances the "last covered day" and the next run does not re-freeze or double-count the same missed day. Co-Authored-By: Claude Fable 5 --- __tests__/cron/updateCurrentStreak.ts | 139 +++++++++++++++- src/common/typedPubsub.ts | 8 + src/common/users.ts | 224 +++++++++++++++++++++++--- src/cron/updateCurrentStreak.ts | 71 +++++++- 4 files changed, 415 insertions(+), 27 deletions(-) diff --git a/__tests__/cron/updateCurrentStreak.ts b/__tests__/cron/updateCurrentStreak.ts index bd8372b89e..d43afcec0e 100644 --- a/__tests__/cron/updateCurrentStreak.ts +++ b/__tests__/cron/updateCurrentStreak.ts @@ -1,12 +1,13 @@ import { crons } from '../../src/cron/index'; import cron from '../../src/cron/updateCurrentStreak'; import { usersFixture } from '../fixture'; -import { User, UserStreak } from '../../src/entity'; +import { Settings, User, UserStreak } from '../../src/entity'; import { doNotFake, expectSuccessfulCron, saveFixtures } from '../helpers'; import { DataSource } from 'typeorm'; import createOrGetConnection from '../../src/db'; import nock from 'nock'; import { UserStreakAction, UserStreakActionType } from '../../src/entity'; +import { CoresRole } from '../../src/types'; let con: DataSource; @@ -244,4 +245,140 @@ describe('updateCurrentStreak cron', () => { expect(streak.currentStreak).toBe(0); }); }); + + describe('incorporating streak freeze', () => { + beforeEach(async () => { + nock('http://localhost:5000').post('/e').reply(204); + await con + .getRepository(User) + .update({ id: '1' }, { coresRole: CoresRole.User }); + }); + + it('should consume a single streak freeze instead of resetting when one day was missed', async () => { + await con + .getRepository(UserStreak) + .update({ userId: '1' }, { freezesAvailable: 3 }); + + await expectSuccessfulCron(cron); + + const streak = await con + .getRepository(UserStreak) + .findOneBy({ userId: '1' }); + expect(streak.currentStreak).toBe(1); + expect(streak.freezesAvailable).toBe(2); + + const freezeActions = await con + .getRepository(UserStreakAction) + .findBy({ userId: '1', type: UserStreakActionType.Freeze }); + expect(freezeActions).toHaveLength(1); + expect(freezeActions[0].createdAt).toEqual(new Date('2024-06-25')); + }); + + it('should consume one freeze per missed day when the gap spans multiple days', async () => { + await con.getRepository(UserStreak).update( + { userId: '1' }, + { + lastViewAt: new Date('2024-06-23'), // Sunday + freezesAvailable: 2, + }, + ); + + await expectSuccessfulCron(cron); + + const streak = await con + .getRepository(UserStreak) + .findOneBy({ userId: '1' }); + expect(streak.currentStreak).toBe(1); + expect(streak.freezesAvailable).toBe(0); + + const freezeActions = await con + .getRepository(UserStreakAction) + .findBy({ userId: '1', type: UserStreakActionType.Freeze }); + expect(freezeActions).toHaveLength(2); + const sortedTimes = freezeActions + .map(({ createdAt }) => createdAt.getTime()) + .sort((a, b) => a - b); + expect(sortedTimes).toEqual([ + new Date('2024-06-24').getTime(), + new Date('2024-06-25').getTime(), + ]); + }); + + it('should reset the streak when the freeze balance cannot cover the whole gap', async () => { + await con.getRepository(UserStreak).update( + { userId: '1' }, + { + lastViewAt: new Date('2024-06-23'), // Sunday + freezesAvailable: 1, + }, + ); + + await expectSuccessfulCron(cron); + + const streak = await con + .getRepository(UserStreak) + .findOneBy({ userId: '1' }); + expect(streak.currentStreak).toBe(0); + expect(streak.freezesAvailable).toBe(1); + + const freezeActions = await con + .getRepository(UserStreakAction) + .findBy({ userId: '1', type: UserStreakActionType.Freeze }); + expect(freezeActions).toHaveLength(0); + }); + + it('should reset the streak when the user opted out of automated streak freeze', async () => { + await con + .getRepository(UserStreak) + .update({ userId: '1' }, { freezesAvailable: 3 }); + await con + .getRepository(Settings) + .save({ userId: '1', optOutStreakFreeze: true }); + + await expectSuccessfulCron(cron); + + const streak = await con + .getRepository(UserStreak) + .findOneBy({ userId: '1' }); + expect(streak.currentStreak).toBe(0); + expect(streak.freezesAvailable).toBe(3); + }); + + it('should reset the streak when the user has no Cores access, even with freezes available', async () => { + await con + .getRepository(User) + .update({ id: '1' }, { coresRole: CoresRole.None }); + await con + .getRepository(UserStreak) + .update({ userId: '1' }, { freezesAvailable: 3 }); + + await expectSuccessfulCron(cron); + + const streak = await con + .getRepository(UserStreak) + .findOneBy({ userId: '1' }); + expect(streak.currentStreak).toBe(0); + expect(streak.freezesAvailable).toBe(3); + }); + + it('should not consume a freeze twice for the same missed day across consecutive cron runs', async () => { + await con + .getRepository(UserStreak) + .update({ userId: '1' }, { freezesAvailable: 3 }); + + await expectSuccessfulCron(cron); + await expectSuccessfulCron(cron); + + const streak = await con + .getRepository(UserStreak) + .findOneBy({ userId: '1' }); + expect(streak.currentStreak).toBe(1); + expect(streak.freezesAvailable).toBe(2); + + const freezeActions = await con + .getRepository(UserStreakAction) + .findBy({ userId: '1', type: UserStreakActionType.Freeze }); + expect(freezeActions).toHaveLength(1); + }); + }); }); diff --git a/src/common/typedPubsub.ts b/src/common/typedPubsub.ts index 1672ae8383..febb801c1b 100644 --- a/src/common/typedPubsub.ts +++ b/src/common/typedPubsub.ts @@ -127,6 +127,14 @@ export type PubSubSchema = { }; 'api.v1.user-streak-updated': { streak: ChangeObject; + /** + * Present only when this event was published because a streak freeze was + * consumed to avoid a reset (see src/common/users.ts#tryConsumeStreakFreeze). + */ + freeze?: { + date: string; // ISO 8601 str, the missed day that was frozen + remainingFreezes: number; + }; }; 'api.v1.source-post-moderation-approved': { post: ChangeObject; diff --git a/src/common/users.ts b/src/common/users.ts index f9af31ffd5..4f3057eea4 100644 --- a/src/common/users.ts +++ b/src/common/users.ts @@ -7,21 +7,33 @@ import { UserStreakActionType, type Organization, } from '../entity'; -import { differenceInDays, isSameDay, max, startOfDay } from 'date-fns'; +import { + differenceInDays, + isSameDay, + max, + startOfDay, + subDays, +} from 'date-fns'; import { DataSource, EntityManager, In, Not } from 'typeorm'; import { CommentMention, Comment, View, Source, SourceMember } from '../entity'; -import { getTimezonedStartOfISOWeek, getTimezonedEndOfISOWeek } from './utils'; +import { + getTimezonedStartOfISOWeek, + getTimezonedEndOfISOWeek, + toChangeObject, +} from './utils'; import { GraphQLResolveInfo } from 'graphql'; import { utcToZonedTime } from 'date-fns-tz'; import { sendAnalyticsEvent } from '../integrations/analytics'; import { DayOfWeek, DEFAULT_TIMEZONE, DEFAULT_WEEK_START } from './date'; -import { ChangeObject, ContentLanguage } from '../types'; +import { ChangeObject, ContentLanguage, CoresRole } from '../types'; import { checkRestoreValidity } from './streak'; import { queryReadReplica } from './queryReadReplica'; import { logger } from '../logger'; import type { GQLKeyword } from '../schema/keywords'; import type { GQLUser } from '../schema/users'; import { UserExperienceType } from '../entity/user/experiences/types'; +import { checkUserCoresAccess } from './user'; +import { triggerTypedEvent } from './typedPubsub'; /** * Reputation at or below this value is treated as "thin" and triggers @@ -53,6 +65,7 @@ export interface GQLUserStreak { lastViewAtTz?: Date; userId: string; weekStart: DayOfWeek; + freezesAvailable?: number; } export interface GQLCompany { @@ -81,6 +94,8 @@ export interface GQLUserStreakTz extends GQLUserStreak { timezone: string; lastViewAtTz: Date; optOutReadingStreak?: boolean; + optOutStreakFreeze?: boolean; + coresRole?: number | null; } export const fetchUser = async ( @@ -510,14 +525,12 @@ export const clearUserStreak = async ( return result.affected || 0; }; -// Computes whether we should reset user streak -// Even though it is the weekend, we should still clear the streak for when the user's last read was Thursday -// Due to the fact that when Monday comes, we will clear it anyway when we notice the gap in Friday -export const shouldResetStreak = ( +// Number of grace days allowed before a streak reset is triggered for a given +// day of the week (weekends grant extra tolerance, see shouldResetStreak) +export const getStreakGraceDays = ( day: number, - difference: number, startOfWeek: DayOfWeek = DEFAULT_WEEK_START, -) => { +): number => { const firstDayOfWeek = startOfWeek === DayOfWeek.Monday ? Day.Monday : Day.Sunday; @@ -525,27 +538,41 @@ export const shouldResetStreak = ( startOfWeek === DayOfWeek.Monday ? Day.Sunday : Day.Saturday; if (day === lastDayOfWeek) { - return difference > FREEZE_DAYS_IN_A_WEEK; + return FREEZE_DAYS_IN_A_WEEK; } if (day === firstDayOfWeek) { - return difference > FREEZE_DAYS_IN_A_WEEK + MISSED_LIMIT; + return FREEZE_DAYS_IN_A_WEEK + MISSED_LIMIT; } - return day > firstDayOfWeek && difference > MISSED_LIMIT; + return MISSED_LIMIT; }; -export const checkUserStreak = ( +// Computes whether we should reset user streak +// Even though it is the weekend, we should still clear the streak for when the user's last read was Thursday +// Due to the fact that when Monday comes, we will clear it anyway when we notice the gap in Friday +export const shouldResetStreak = ( + day: number, + difference: number, + startOfWeek: DayOfWeek = DEFAULT_WEEK_START, +) => difference > getStreakGraceDays(day, startOfWeek); + +const getStreakResetContext = ( streak: GQLUserStreakTz, - lastRecoveredTime?: Date, -): boolean => { + lastActionTime?: Date, +): { + shouldReset: boolean; + today: Date | null; + day: number; + difference: number; +} => { const { lastViewAtTz: lastViewAt, timezone, current } = streak; - const lastStreakUpdate = lastRecoveredTime - ? max([lastViewAt, lastRecoveredTime]) + const lastStreakUpdate = lastActionTime + ? max([lastViewAt, lastActionTime]) : lastViewAt; if (!lastViewAt || current === 0) { - return false; + return { shouldReset: false, today: null, day: 0, difference: 0 }; } const today = utcToZonedTime(new Date(), timezone); @@ -554,7 +581,48 @@ export const checkUserStreak = ( const day = today.getDay(); const difference = differenceInDays(today, lastStreakUpdate); - return shouldResetStreak(day, difference, streak.weekStart); + return { + shouldReset: shouldResetStreak(day, difference, streak.weekStart), + today, + day, + difference, + }; +}; + +export const checkUserStreak = ( + streak: GQLUserStreakTz, + lastActionTime?: Date, +): boolean => getStreakResetContext(streak, lastActionTime).shouldReset; + +// The calendar days (in the user's timezone) that were missed and caused a +// reset to be triggered, i.e. the days beyond the free grace period. Returns +// an empty array when no reset would be triggered. +export const getMissedStreakDays = ( + streak: GQLUserStreakTz, + lastActionTime?: Date, +): Date[] => { + const { shouldReset, today, day, difference } = getStreakResetContext( + streak, + lastActionTime, + ); + + if (!shouldReset || !today) { + return []; + } + + const missedCount = difference - getStreakGraceDays(day, streak.weekStart); + + return Array.from({ length: missedCount }, (_, index) => + subDays(today, missedCount - index), + ); +}; + +export const combineLastActionDates = ( + dates: Array, +): Date | undefined => { + const validDates = dates.filter((date): date is Date => !!date); + + return validDates.length ? max(validDates) : undefined; }; export const getLastStreakRecoverDate = async ( @@ -576,18 +644,132 @@ export const getLastStreakRecoverDate = async ( return lastRecoverAction?.createdAt; }; +// Unlike recover actions, a freeze action's createdAt is set to the exact +// missed calendar day it covers, so no timezone shifting is needed when +// reading it back. +export const getLastStreakFreezeDate = async ( + con: DataSource | EntityManager, + userId: string, +) => { + const lastFreezeAction = await con + .getRepository(UserStreakAction) + .createQueryBuilder('usa') + .select(`MAX(usa."createdAt")`, 'createdAt') + .where(`usa."userId" = :userId`, { userId }) + .andWhere(`usa.type = :type`, { type: UserStreakActionType.Freeze }) + .getRawOne(); + + return lastFreezeAction?.createdAt; +}; + +export const MAX_STREAK_FREEZES = 5; + +export type StreakFreezeCandidate = { + userId: string; + currentStreak: number; + freezesAvailable: number; + optOutStreakFreeze?: boolean; + coresRole?: number | null; + missedDays: Date[]; +}; + +// Attempts to consume one streak freeze per missed day. Returns true when the +// streak was frozen (and therefore should NOT be reset), false otherwise (the +// caller is expected to fall back to resetting the streak). +export const tryConsumeStreakFreeze = async ( + con: DataSource | EntityManager, + candidate: StreakFreezeCandidate, +): Promise => { + const { + userId, + missedDays, + freezesAvailable, + optOutStreakFreeze, + coresRole, + } = candidate; + + if (!missedDays.length || optOutStreakFreeze) { + return false; + } + + if ( + !checkUserCoresAccess({ + user: { id: userId, coresRole: coresRole ?? CoresRole.None }, + requiredRole: CoresRole.User, + }) + ) { + return false; + } + + if (freezesAvailable < missedDays.length) { + return false; + } + + const remainingFreezes = freezesAvailable - missedDays.length; + + const updatedStreak = await con.transaction(async (entityManager) => { + await entityManager.getRepository(UserStreakAction).save( + missedDays.map((createdAt) => ({ + userId, + type: UserStreakActionType.Freeze, + createdAt, + })), + ); + + await entityManager.getRepository(UserStreak).update( + { userId }, + { + freezesAvailable: remainingFreezes, + updatedAt: new Date(), + }, + ); + + return entityManager.getRepository(UserStreak).findOneByOrFail({ userId }); + }); + + for (const [index, date] of missedDays.entries()) { + await triggerTypedEvent(logger, 'api.v1.user-streak-updated', { + streak: toChangeObject(updatedStreak), + freeze: { + date: date.toISOString(), + remainingFreezes: freezesAvailable - (index + 1), + }, + }); + } + + return true; +}; + export const checkAndClearUserStreak = async ( con: DataSource | EntityManager, info: GraphQLResolveInfo, streak: GQLUserStreakTz, ): Promise => { - const lastStreak = await getLastStreakRecoverDate(con, streak.userId); - const shouldClear = checkUserStreak(streak, lastStreak); + const [lastRecoverAt, lastFreezeAt] = await Promise.all([ + getLastStreakRecoverDate(con, streak.userId), + getLastStreakFreezeDate(con, streak.userId), + ]); + const lastActionTime = combineLastActionDates([lastRecoverAt, lastFreezeAt]); + const shouldClear = checkUserStreak(streak, lastActionTime); if (!shouldClear) { return false; } + const missedDays = getMissedStreakDays(streak, lastActionTime); + const frozen = await tryConsumeStreakFreeze(con, { + userId: streak.userId, + currentStreak: streak.current, + freezesAvailable: streak.freezesAvailable ?? 0, + optOutStreakFreeze: streak.optOutStreakFreeze, + coresRole: streak.coresRole, + missedDays, + }); + + if (frozen) { + return false; + } + const result = await clearUserStreak(con, [streak.userId]); const clearedSuccess = result > 0; diff --git a/src/cron/updateCurrentStreak.ts b/src/cron/updateCurrentStreak.ts index afbe08f1d9..7c368bd1fb 100644 --- a/src/cron/updateCurrentStreak.ts +++ b/src/cron/updateCurrentStreak.ts @@ -5,7 +5,14 @@ import { UserStreakAction, UserStreakActionType, } from '../entity'; -import { checkUserStreak, clearUserStreak } from '../common'; +import { Settings } from '../entity/Settings'; +import { + checkUserStreak, + clearUserStreak, + combineLastActionDates, + getMissedStreakDays, + tryConsumeStreakFreeze, +} from '../common/users'; import { counters } from '../telemetry'; const cron: Cron = { @@ -22,25 +29,47 @@ const cron: Cron = { 'lastViewAtTz', ) .addSelect('u.timezone', 'timezone') + .addSelect('u."coresRole"', 'coresRole') .addSelect('us.currentStreak', 'current') .addSelect('u."weekStart"', 'weekStart') + .addSelect( + 'COALESCE(s."optOutStreakFreeze", false)', + 'optOutStreakFreeze', + ) .addSelect( `(date_trunc('day', usa."lastRecoverAt"::timestamptz at time zone COALESCE(u.timezone, 'utc'))::date) - interval '1 day'`, 'lastRecoverAt', ) + .addSelect(`usf."lastFreezeAt"`, 'lastFreezeAt') .from(UserStreak, 'us') .innerJoin(User, 'u', 'u.id = us."userId"') + .leftJoin(Settings, 's', 's."userId" = u.id') .leftJoin( (qb) => qb .select('MAX(a."createdAt")', 'lastRecoverAt') .addSelect('a."userId"', 'userId') .from(UserStreakAction, 'a') - .where(`a.type = :type`, { type: UserStreakActionType.Recover }) + .where(`a.type = :recoverType`, { + recoverType: UserStreakActionType.Recover, + }) .groupBy('a."userId"'), 'usa', 'usa."userId" = us."userId"', ) + .leftJoin( + (qb) => + qb + .select('MAX(a."createdAt")', 'lastFreezeAt') + .addSelect('a."userId"', 'userId') + .from(UserStreakAction, 'a') + .where(`a.type = :freezeType`, { + freezeType: UserStreakActionType.Freeze, + }) + .groupBy('a."userId"'), + 'usf', + 'usf."userId" = us."userId"', + ) .where(`us."currentStreak" != 0`) .andWhere( `(date_trunc('day', us."lastViewAt" at time zone COALESCE(u.timezone, 'utc'))::date) < (date_trunc('day', now() at time zone COALESCE(u.timezone, 'utc'))::date) - interval '1 day'`, @@ -56,14 +85,46 @@ const cron: Cron = { ) )`, ) + .andWhere( + ` + ( + usf."lastFreezeAt" IS NULL OR + ( + usf."lastFreezeAt"::date + < + (date_trunc('day', now() at time zone COALESCE(u.timezone, 'utc'))::date) + ) + )`, + ) .getRawMany(); const userIdsToReset: string[] = []; - usersPastStreakTime.forEach(({ lastRecoverAt, ...userStreak }) => { - if (checkUserStreak(userStreak, lastRecoverAt)) { + + for (const row of usersPastStreakTime) { + const { lastRecoverAt, lastFreezeAt, ...userStreak } = row; + const lastActionTime = combineLastActionDates([ + lastRecoverAt, + lastFreezeAt, + ]); + + if (!checkUserStreak(userStreak, lastActionTime)) { + continue; + } + + const missedDays = getMissedStreakDays(userStreak, lastActionTime); + const frozen = await tryConsumeStreakFreeze(entityManager, { + userId: userStreak.userId, + currentStreak: userStreak.currentStreak, + freezesAvailable: userStreak.freezesAvailable, + optOutStreakFreeze: userStreak.optOutStreakFreeze, + coresRole: userStreak.coresRole, + missedDays, + }); + + if (!frozen) { userIdsToReset.push(userStreak.userId); } - }); + } if (!userIdsToReset.length) { logger.info('no user streaks to reset'); From eff95fbac5d19054d9f98098cb849e362bced0f6 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Thu, 16 Jul 2026 20:07:10 +0200 Subject: [PATCH 3/6] feat(streak-freeze): notify users when a freeze is used or depleted Adds streak_freeze_used and streak_freeze_depleted notification types, title/builder entries, and a worker subscribed to the existing api.v1.user-streak-updated topic that reacts only to the freeze discriminator payload (ignores the regular CDC-driven updates other subscribers on that topic care about). Emits StreakFreezeUsed per frozen day, plus StreakFreezeDepleted when no freezes remain; uniqueKey is the frozen day so repeated cron runs stay idempotent at the notification layer. No email template yet, so email defaults to muted like other placeholder notification types. Co-Authored-By: Claude Fable 5 --- .infra/common.ts | 4 + .../notifications/streakFreezeNotification.ts | 74 +++++++++++++++++++ src/notifications/common.ts | 10 +++ src/notifications/generate.ts | 19 +++++ src/notifications/types.ts | 7 ++ src/workers/newNotificationV2Mail.ts | 4 + src/workers/notifications/index.ts | 2 + .../notifications/streakFreezeNotification.ts | 31 ++++++++ 8 files changed, 151 insertions(+) create mode 100644 __tests__/workers/notifications/streakFreezeNotification.ts create mode 100644 src/workers/notifications/streakFreezeNotification.ts diff --git a/.infra/common.ts b/.infra/common.ts index 6acbaa280e..adaf5dc357 100644 --- a/.infra/common.ts +++ b/.infra/common.ts @@ -307,6 +307,10 @@ export const workers: Worker[] = [ topic: 'api.v1.user-streak-updated', subscription: 'api.user-streak-reset-notification', }, + { + topic: 'api.v1.user-streak-updated', + subscription: 'api.streak-freeze-used-notification', + }, { topic: 'api.v1.squad-featured-updated', subscription: 'api.squad-featured-updated-notification', diff --git a/__tests__/workers/notifications/streakFreezeNotification.ts b/__tests__/workers/notifications/streakFreezeNotification.ts new file mode 100644 index 0000000000..aa30e59fcc --- /dev/null +++ b/__tests__/workers/notifications/streakFreezeNotification.ts @@ -0,0 +1,74 @@ +import worker from '../../../src/workers/notifications/streakFreezeNotification'; +import { workers } from '../../../src/workers'; +import { invokeTypedNotificationWorker } from '../../helpers'; +import type { NotificationStreakFreezeContext } from '../../../src/notifications'; +import { NotificationType } from '../../../src/notifications/common'; + +const streak = { + userId: '1', + currentStreak: 5, + totalStreak: 20, + maxStreak: 10, + freezesAvailable: 2, + lastViewAt: Date.now(), + updatedAt: Date.now(), +}; + +describe('streakFreezeNotification worker', () => { + it('should be registered', () => { + const registeredWorker = workers.find( + (item) => item.subscription === worker.subscription, + ); + + expect(registeredWorker).toBeDefined(); + }); + + it('should do nothing when the event has no freeze discriminator', async () => { + const result = + await invokeTypedNotificationWorker<'api.v1.user-streak-updated'>( + worker, + { streak }, + ); + + expect(result).toBeUndefined(); + }); + + it('should send a StreakFreezeUsed notification when a freeze was consumed', async () => { + const result = + await invokeTypedNotificationWorker<'api.v1.user-streak-updated'>( + worker, + { + streak, + freeze: { date: '2024-06-25T00:00:00.000Z', remainingFreezes: 1 }, + }, + ); + + expect(result).toHaveLength(1); + expect(result[0].type).toEqual(NotificationType.StreakFreezeUsed); + + const ctx = result[0].ctx as NotificationStreakFreezeContext; + expect(ctx.userIds).toEqual(['1']); + expect(ctx.streak).toEqual(streak); + expect(ctx.freeze).toEqual({ + date: '2024-06-25T00:00:00.000Z', + remainingFreezes: 1, + }); + }); + + it('should also send a StreakFreezeDepleted notification when no freezes remain', async () => { + const result = + await invokeTypedNotificationWorker<'api.v1.user-streak-updated'>( + worker, + { + streak, + freeze: { date: '2024-06-25T00:00:00.000Z', remainingFreezes: 0 }, + }, + ); + + expect(result).toHaveLength(2); + expect(result.map(({ type }) => type)).toEqual([ + NotificationType.StreakFreezeUsed, + NotificationType.StreakFreezeDepleted, + ]); + }); +}); diff --git a/src/notifications/common.ts b/src/notifications/common.ts index e2c50db633..340545c307 100644 --- a/src/notifications/common.ts +++ b/src/notifications/common.ts @@ -92,6 +92,8 @@ export enum NotificationType { DigestReady = 'digest_ready', LiveRoomStarted = 'live_room_started', LiveRoomStartingSoon = 'live_room_starting_soon', + StreakFreezeUsed = 'streak_freeze_used', + StreakFreezeDepleted = 'streak_freeze_depleted', } export enum NotificationPreferenceType { @@ -179,6 +181,14 @@ export const DEFAULT_NOTIFICATION_SETTINGS: UserNotificationFlags = { email: NotificationPreferenceStatus.Subscribed, inApp: NotificationPreferenceStatus.Subscribed, }, + [NotificationType.StreakFreezeUsed]: { + email: NotificationPreferenceStatus.Muted, + inApp: NotificationPreferenceStatus.Subscribed, + }, + [NotificationType.StreakFreezeDepleted]: { + email: NotificationPreferenceStatus.Muted, + inApp: NotificationPreferenceStatus.Subscribed, + }, ['streak_reminder']: { email: NotificationPreferenceStatus.Subscribed, inApp: NotificationPreferenceStatus.Subscribed, diff --git a/src/notifications/generate.ts b/src/notifications/generate.ts index 7755775250..23fd108e0e 100644 --- a/src/notifications/generate.ts +++ b/src/notifications/generate.ts @@ -33,6 +33,7 @@ import { type NotificationPostAnalyticsContext, type NotificationWarmIntroContext, type NotificationStreakRestoreContext, + type NotificationStreakFreezeContext, type NotificationParsedCVProfileContext, type NotificationRecruiterNewCandidateContext, type NotificationRecruiterOpportunityLiveContext, @@ -148,6 +149,10 @@ export const notificationTitleMap: Record< squad_public_submitted: systemTitle, streak_reset_restore: (ctx: NotificationStreakRestoreContext) => `Your ${ctx.restore.amount}-day streak was broken`, + streak_freeze_used: (ctx: NotificationStreakFreezeContext) => + `Your streak freeze saved your ${ctx.streak.currentStreak}-day streak — ${ctx.freeze.remainingFreezes} freeze${ctx.freeze.remainingFreezes === 1 ? '' : 's'} left`, + streak_freeze_depleted: () => + `That was your last streak freeze — restock to keep your streak safe`, user_post_added: (ctx: NotificationUserContext) => { const userName = ctx.user.name || ctx.user.username; @@ -337,6 +342,20 @@ export const generateNotificationMap: Record< .setTargetUrlParameter([ ['streak_restore', ctx.restore.amount.toString()], ]), + streak_freeze_used: (builder, ctx: NotificationStreakFreezeContext) => + builder + .icon(NotificationIcon.Streak) + .description('A streak freeze was used to protect your progress') + .uniqueKey(ctx.freeze.date) + .targetUrl(notificationsLink) + .referenceStreak(ctx.streak), + streak_freeze_depleted: (builder, ctx: NotificationStreakFreezeContext) => + builder + .icon(NotificationIcon.Streak) + .description('Restock your streak freezes to keep your streak safe') + .uniqueKey(ctx.freeze.date) + .targetUrl(notificationsLink) + .referenceStreak(ctx.streak), article_upvote_milestone: ( builder, ctx: NotificationPostContext & NotificationUpvotersContext, diff --git a/src/notifications/types.ts b/src/notifications/types.ts index ccf96ac7c0..53bf8c68e4 100644 --- a/src/notifications/types.ts +++ b/src/notifications/types.ts @@ -88,6 +88,13 @@ export interface NotificationStreakRestoreContext extends NotificationStreakCont }; } +export interface NotificationStreakFreezeContext extends NotificationStreakContext { + freeze: { + date: string; + remainingFreezes: number; + }; +} + export type NotificationGiftPlusContext = NotificationBaseContext & { gifter: Reference; recipient: Reference; diff --git a/src/workers/newNotificationV2Mail.ts b/src/workers/newNotificationV2Mail.ts index 6f88fbb4b0..4d05462d4d 100644 --- a/src/workers/newNotificationV2Mail.ts +++ b/src/workers/newNotificationV2Mail.ts @@ -144,6 +144,8 @@ export const notificationToTemplateId: Record = { achievement_unlocked: '', // No email for achievement unlocks live_room_started: '', live_room_starting_soon: '', + streak_freeze_used: '', + streak_freeze_depleted: '', }; type TemplateData = Record & { @@ -286,6 +288,8 @@ const notificationToTemplateData: Record = { post_bookmark_reminder: async () => null, scheduled_post_published: async () => null, streak_reset_restore: async () => null, + streak_freeze_used: async () => null, + streak_freeze_depleted: async () => null, community_picks_failed: async (con, user, notification) => { const submission = await con .getRepository(Submission) diff --git a/src/workers/notifications/index.ts b/src/workers/notifications/index.ts index 051290e221..3e329a88a7 100644 --- a/src/workers/notifications/index.ts +++ b/src/workers/notifications/index.ts @@ -47,6 +47,7 @@ import { reMatchedOpportunityNotification } from './reMatchedOpportunityNotifica import { achievementUnlockedNotification } from './achievementUnlockedNotification'; import { liveRoomStartingSoonNotification } from './liveRoomStartingSoonNotification'; import { scheduledPostPublishedNotification } from './scheduledPostPublishedNotification'; +import streakFreezeNotification from './streakFreezeNotification'; export function notificationWorkerToWorker( // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -140,6 +141,7 @@ const notificationWorkers: TypedNotificationWorker[] = [ achievementUnlockedNotification, liveRoomStartingSoonNotification, scheduledPostPublishedNotification, + streakFreezeNotification, ]; export const workers = [...notificationWorkers.map(notificationWorkerToWorker)]; diff --git a/src/workers/notifications/streakFreezeNotification.ts b/src/workers/notifications/streakFreezeNotification.ts new file mode 100644 index 0000000000..6c5b85d504 --- /dev/null +++ b/src/workers/notifications/streakFreezeNotification.ts @@ -0,0 +1,31 @@ +import { NotificationType } from '../../notifications/common'; +import { TypedNotificationWorker } from '../worker'; +import type { NotificationHandlerReturn } from './worker'; +import type { NotificationStreakFreezeContext } from '../../notifications'; + +const worker: TypedNotificationWorker<'api.v1.user-streak-updated'> = { + subscription: 'api.streak-freeze-used-notification', + handler: async ({ streak, freeze }) => { + if (!freeze) { + return; + } + + const ctx: NotificationStreakFreezeContext = { + userIds: [streak.userId], + streak, + freeze, + }; + + const notifications: NonNullable = [ + { type: NotificationType.StreakFreezeUsed, ctx }, + ]; + + if (freeze.remainingFreezes === 0) { + notifications.push({ type: NotificationType.StreakFreezeDepleted, ctx }); + } + + return notifications; + }, +}; + +export default worker; From d2aa22e38ca7e31c1540ee14a7d067b3756e4c32 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Mon, 20 Jul 2026 15:47:07 +0200 Subject: [PATCH 4/6] fix(streak-freeze): allow freeze notification types in notificationFlagsSchema The updateNotificationSettings zod schema strips unknown keys, so the new streak_freeze_used/streak_freeze_depleted preferences were silently dropped on write. Co-Authored-By: Claude Fable 5 --- src/common/schema/notificationFlagsSchema.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/common/schema/notificationFlagsSchema.ts b/src/common/schema/notificationFlagsSchema.ts index 89a5a53187..b0c77d5415 100644 --- a/src/common/schema/notificationFlagsSchema.ts +++ b/src/common/schema/notificationFlagsSchema.ts @@ -17,6 +17,8 @@ export const notificationFlagsSchema = z.object({ [NotificationType.ArticleReportApproved]: notificationPreferenceSchema, [NotificationType.UserFollow]: notificationPreferenceSchema, [NotificationType.StreakResetRestore]: notificationPreferenceSchema, + [NotificationType.StreakFreezeUsed]: notificationPreferenceSchema, + [NotificationType.StreakFreezeDepleted]: notificationPreferenceSchema, [UserPersonalizedDigestType.StreakReminder]: notificationPreferenceSchema, [NotificationType.UserTopReaderBadge]: notificationPreferenceSchema, [NotificationType.DevCardUnlocked]: notificationPreferenceSchema, From 5db75ade9ea76179046180e74eaa9100dd7249c8 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Tue, 21 Jul 2026 09:57:48 +0200 Subject: [PATCH 5/6] fix(streak-freeze): publish events after commit, atomic freeze decrement, reject restricted products MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tryConsumeStreakFreeze now returns freeze events instead of publishing them, so the cron publishes after its transaction commits — a rollback can no longer emit notifications for freezes that were never consumed - the freeze decrement is a guarded atomic UPDATE instead of writing a snapshot-derived value, so concurrent purchases aren't clobbered - purchaseStreakFreeze rejects restricted products, matching the streakFreezeProducts query filter Co-Authored-By: Claude Fable 5 --- __tests__/users.ts | 14 ++++++ src/common/users.ts | 82 ++++++++++++++++++++++----------- src/cron/updateCurrentStreak.ts | 13 +++++- src/schema/users.ts | 8 +++- 4 files changed, 85 insertions(+), 32 deletions(-) diff --git a/__tests__/users.ts b/__tests__/users.ts index 5f3dd93d7e..e9db5d88b2 100644 --- a/__tests__/users.ts +++ b/__tests__/users.ts @@ -1923,6 +1923,20 @@ describe('purchaseStreakFreeze mutation', () => { ); }); + it('should throw when the product is restricted', async () => { + loggedUser = '1-psf'; + + await con + .getRepository(Product) + .update({ id: productId }, { flags: { quantity: 3, restricted: true } }); + + await testMutationErrorCode( + client, + { mutation: MUTATION, variables: { productId } }, + 'GRAPHQL_VALIDATION_FAILED', + ); + }); + it('should throw when the user does not have enough Cores', async () => { loggedUser = '1-psf'; diff --git a/src/common/users.ts b/src/common/users.ts index 4f3057eea4..b41617e532 100644 --- a/src/common/users.ts +++ b/src/common/users.ts @@ -673,13 +673,20 @@ export type StreakFreezeCandidate = { missedDays: Date[]; }; -// Attempts to consume one streak freeze per missed day. Returns true when the -// streak was frozen (and therefore should NOT be reset), false otherwise (the -// caller is expected to fall back to resetting the streak). +export type StreakFreezeEvent = { + streak: ChangeObject; + freeze: { date: string; remainingFreezes: number }; +}; + +// Attempts to consume one streak freeze per missed day. Returns the freeze +// events to publish when the streak was frozen (and therefore should NOT be +// reset), null otherwise (the caller is expected to fall back to resetting +// the streak). Events are returned instead of published so callers running +// inside a transaction can publish them after commit. export const tryConsumeStreakFreeze = async ( con: DataSource | EntityManager, candidate: StreakFreezeCandidate, -): Promise => { +): Promise => { const { userId, missedDays, @@ -689,7 +696,7 @@ export const tryConsumeStreakFreeze = async ( } = candidate; if (!missedDays.length || optOutStreakFreeze) { - return false; + return null; } if ( @@ -698,16 +705,33 @@ export const tryConsumeStreakFreeze = async ( requiredRole: CoresRole.User, }) ) { - return false; + return null; } if (freezesAvailable < missedDays.length) { - return false; + return null; } - const remainingFreezes = freezesAvailable - missedDays.length; - const updatedStreak = await con.transaction(async (entityManager) => { + // Guarded atomic decrement: a concurrent purchase or consumption between + // the caller's snapshot and this write must not be clobbered. + const decrement = await entityManager + .getRepository(UserStreak) + .createQueryBuilder() + .update() + .set({ + freezesAvailable: () => '"freezesAvailable" - :count', + updatedAt: new Date(), + }) + .where('"userId" = :userId', { userId }) + .andWhere('"freezesAvailable" >= :count') + .setParameter('count', missedDays.length) + .execute(); + + if (!decrement.affected) { + return null; + } + await entityManager.getRepository(UserStreakAction).save( missedDays.map((createdAt) => ({ userId, @@ -716,28 +740,29 @@ export const tryConsumeStreakFreeze = async ( })), ); - await entityManager.getRepository(UserStreak).update( - { userId }, - { - freezesAvailable: remainingFreezes, - updatedAt: new Date(), - }, - ); - return entityManager.getRepository(UserStreak).findOneByOrFail({ userId }); }); - for (const [index, date] of missedDays.entries()) { - await triggerTypedEvent(logger, 'api.v1.user-streak-updated', { - streak: toChangeObject(updatedStreak), - freeze: { - date: date.toISOString(), - remainingFreezes: freezesAvailable - (index + 1), - }, - }); + if (!updatedStreak) { + return null; } - return true; + return missedDays.map((date, index) => ({ + streak: toChangeObject(updatedStreak), + freeze: { + date: date.toISOString(), + remainingFreezes: + updatedStreak.freezesAvailable + missedDays.length - (index + 1), + }, + })); +}; + +export const publishStreakFreezeEvents = async ( + events: StreakFreezeEvent[], +): Promise => { + for (const event of events) { + await triggerTypedEvent(logger, 'api.v1.user-streak-updated', event); + } }; export const checkAndClearUserStreak = async ( @@ -757,7 +782,7 @@ export const checkAndClearUserStreak = async ( } const missedDays = getMissedStreakDays(streak, lastActionTime); - const frozen = await tryConsumeStreakFreeze(con, { + const freezeEvents = await tryConsumeStreakFreeze(con, { userId: streak.userId, currentStreak: streak.current, freezesAvailable: streak.freezesAvailable ?? 0, @@ -766,7 +791,8 @@ export const checkAndClearUserStreak = async ( missedDays, }); - if (frozen) { + if (freezeEvents) { + await publishStreakFreezeEvents(freezeEvents); return false; } diff --git a/src/cron/updateCurrentStreak.ts b/src/cron/updateCurrentStreak.ts index 7c368bd1fb..5f74d195df 100644 --- a/src/cron/updateCurrentStreak.ts +++ b/src/cron/updateCurrentStreak.ts @@ -11,8 +11,10 @@ import { clearUserStreak, combineLastActionDates, getMissedStreakDays, + publishStreakFreezeEvents, tryConsumeStreakFreeze, } from '../common/users'; +import type { StreakFreezeEvent } from '../common/users'; import { counters } from '../telemetry'; const cron: Cron = { @@ -20,6 +22,10 @@ const cron: Cron = { handler: async (con, logger) => { try { const streakCounter = counters?.cron?.streakUpdate; + // Freeze events are published after the transaction commits, so a + // rollback cannot leave users with notifications for freezes that + // were never consumed. + const freezeEvents: StreakFreezeEvent[] = []; await con.transaction(async (entityManager): Promise => { const usersPastStreakTime = await entityManager .createQueryBuilder() @@ -112,7 +118,7 @@ const cron: Cron = { } const missedDays = getMissedStreakDays(userStreak, lastActionTime); - const frozen = await tryConsumeStreakFreeze(entityManager, { + const userFreezeEvents = await tryConsumeStreakFreeze(entityManager, { userId: userStreak.userId, currentStreak: userStreak.currentStreak, freezesAvailable: userStreak.freezesAvailable, @@ -121,7 +127,9 @@ const cron: Cron = { missedDays, }); - if (!frozen) { + if (userFreezeEvents) { + freezeEvents.push(...userFreezeEvents); + } else { userIdsToReset.push(userStreak.userId); } } @@ -140,6 +148,7 @@ const cron: Cron = { }); streakCounter?.add(clearedStreaks, { type: 'users_updated' }); }); + await publishStreakFreezeEvents(freezeEvents); logger.info('updated current streak cron'); } catch (err) { logger.error({ err }, 'failed to update current streak cron'); diff --git a/src/schema/users.ts b/src/schema/users.ts index 0d5dd87584..72b8164621 100644 --- a/src/schema/users.ts +++ b/src/schema/users.ts @@ -4344,7 +4344,11 @@ export const resolvers: IResolvers = { getBalance(ctx), ]); - if (!product || product.type !== ProductType.StreakFreeze) { + if ( + !product || + product.type !== ProductType.StreakFreeze || + product.flags?.restricted + ) { throw new ValidationError('Invalid streak freeze product'); } @@ -4361,7 +4365,7 @@ export const resolvers: IResolvers = { throw new ConflictError('Not enough Cores to purchase streak freeze'); } - const quantity = product.flags?.quantity || 1; + const quantity = product.flags?.quantity ?? 1; const { transaction, transfer, freezesAvailable } = await ctx.con.transaction(async (entityManager) => { From 037ae15f06688e628e5b39d8e1100cd75b940fc7 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Tue, 21 Jul 2026 11:55:43 +0200 Subject: [PATCH 6/6] test: update newView snapshots for freezesAvailable column Co-Authored-By: Claude Fable 5 --- __tests__/workers/__snapshots__/newView.ts.snap | 2 ++ 1 file changed, 2 insertions(+) diff --git a/__tests__/workers/__snapshots__/newView.ts.snap b/__tests__/workers/__snapshots__/newView.ts.snap index 0e5c393298..e688aa78ca 100644 --- a/__tests__/workers/__snapshots__/newView.ts.snap +++ b/__tests__/workers/__snapshots__/newView.ts.snap @@ -3,6 +3,7 @@ exports[`reading streaks updates reading streak without a timestamp 1`] = ` Object { "currentStreak": 5, + "freezesAvailable": 0, "lastViewAt": Any, "maxStreak": 10, "totalStreak": 43, @@ -44,6 +45,7 @@ View { exports[`should save a new view with the provided timestamp 2`] = ` Object { "currentStreak": 1, + "freezesAvailable": 0, "lastViewAt": 2020-06-11T01:17:00.000Z, "maxStreak": 1, "totalStreak": 1,