diff --git a/.infra/common.ts b/.infra/common.ts index be8a250e43..0cbc030b13 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__/__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__/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/__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..e9db5d88b2 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,269 @@ 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 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'; + + 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/__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, 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/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/common/schema/notificationFlagsSchema.ts b/src/common/schema/notificationFlagsSchema.ts index aa08c2adb7..7e40421358 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, diff --git a/src/common/typedPubsub.ts b/src/common/typedPubsub.ts index 7dcfa627e2..2663074fbe 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..b41617e532 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,158 @@ 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[]; +}; + +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 => { + const { + userId, + missedDays, + freezesAvailable, + optOutStreakFreeze, + coresRole, + } = candidate; + + if (!missedDays.length || optOutStreakFreeze) { + return null; + } + + if ( + !checkUserCoresAccess({ + user: { id: userId, coresRole: coresRole ?? CoresRole.None }, + requiredRole: CoresRole.User, + }) + ) { + return null; + } + + if (freezesAvailable < missedDays.length) { + return null; + } + + 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, + type: UserStreakActionType.Freeze, + createdAt, + })), + ); + + return entityManager.getRepository(UserStreak).findOneByOrFail({ userId }); + }); + + if (!updatedStreak) { + return null; + } + + 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 ( 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 freezeEvents = await tryConsumeStreakFreeze(con, { + userId: streak.userId, + currentStreak: streak.current, + freezesAvailable: streak.freezesAvailable ?? 0, + optOutStreakFreeze: streak.optOutStreakFreeze, + coresRole: streak.coresRole, + missedDays, + }); + + if (freezeEvents) { + await publishStreakFreezeEvents(freezeEvents); + 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..5f74d195df 100644 --- a/src/cron/updateCurrentStreak.ts +++ b/src/cron/updateCurrentStreak.ts @@ -5,7 +5,16 @@ import { UserStreakAction, UserStreakActionType, } from '../entity'; -import { checkUserStreak, clearUserStreak } from '../common'; +import { Settings } from '../entity/Settings'; +import { + checkUserStreak, + clearUserStreak, + combineLastActionDates, + getMissedStreakDays, + publishStreakFreezeEvents, + tryConsumeStreakFreeze, +} from '../common/users'; +import type { StreakFreezeEvent } from '../common/users'; import { counters } from '../telemetry'; const cron: Cron = { @@ -13,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() @@ -22,25 +35,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 +91,48 @@ 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 userFreezeEvents = await tryConsumeStreakFreeze(entityManager, { + userId: userStreak.userId, + currentStreak: userStreak.currentStreak, + freezesAvailable: userStreak.freezesAvailable, + optOutStreakFreeze: userStreak.optOutStreakFreeze, + coresRole: userStreak.coresRole, + missedDays, + }); + + if (userFreezeEvents) { + freezeEvents.push(...userFreezeEvents); + } else { userIdsToReset.push(userStreak.userId); } - }); + } if (!userIdsToReset.length) { logger.info('no user streaks to reset'); @@ -79,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/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/notifications/common.ts b/src/notifications/common.ts index 4b773be4c5..2e7a1ee1b9 100644 --- a/src/notifications/common.ts +++ b/src/notifications/common.ts @@ -94,6 +94,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 { @@ -181,6 +183,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 513e259969..f750735fa6 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, @@ -149,6 +150,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; @@ -346,6 +351,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 e77b8fe1ef..94d26c011b 100644 --- a/src/notifications/types.ts +++ b/src/notifications/types.ts @@ -94,6 +94,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/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..72b8164621 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,119 @@ 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 || + product.flags?.restricted + ) { + 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, diff --git a/src/workers/newNotificationV2Mail.ts b/src/workers/newNotificationV2Mail.ts index 89d8c3733c..234b8fc7a2 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: '', interest_content_available: '', interest_content_batch: '', }; @@ -288,6 +290,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 43fec8a5e1..f38db281f8 100644 --- a/src/workers/notifications/index.ts +++ b/src/workers/notifications/index.ts @@ -48,6 +48,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 @@ -142,6 +143,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;