Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "NotificationPreference" ADD COLUMN "subscriptionCharged" BOOLEAN NOT NULL DEFAULT true;
15 changes: 8 additions & 7 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -182,13 +182,14 @@ model Notification {
/// Per-user toggles controlling which notification types are created for them.
/// Missing rows are treated as all-enabled (see notifications module defaults).
model NotificationPreference {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tipReceived Boolean @default(true)
goalReached Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tipReceived Boolean @default(true)
goalReached Boolean @default(true)
subscriptionCharged Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

/// Cached X (Twitter) account metrics.
Expand Down
21 changes: 21 additions & 0 deletions backend/src/indexer/projections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,27 @@ describe('projectEvent — subscriptions (#900)', () => {
await projectEvent(event('sub_created', [ADDR_A, ADDR_B, 'nope', 7]));
expect(mockSubUpsert).not.toHaveBeenCalled();
});

it('notifies the creator of a new charge (#965)', async () => {
mockEventLogFindFirst.mockResolvedValue(null);
await projectEvent(event('sub_exec', [ADDR_A, ADDR_B, '500']));
expect(mockCreateNotification).toHaveBeenCalledWith('u_' + ADDR_B, 'subscription_charged', {
tipperId: 'u_' + ADDR_A,
amountStroops: '500',
});
});

it('does not re-notify when replaying an already-logged charge (#965)', async () => {
mockEventLogFindFirst.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 'existing' });
await projectEvent(event('sub_exec', [ADDR_A, ADDR_B, '500']));
await projectEvent(event('sub_exec', [ADDR_A, ADDR_B, '500']));
expect(mockCreateNotification).toHaveBeenCalledTimes(1);
});

it('skips notifying when the charge event is unparseable', async () => {
await projectEvent(event('sub_exec', [ADDR_A, ADDR_B, 'nope']));
expect(mockCreateNotification).not.toHaveBeenCalled();
});
});

describe('projectEvent — tip idempotency (#892)', () => {
Expand Down
21 changes: 18 additions & 3 deletions backend/src/indexer/projections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const REFUND_TOPICS = new Set(['refund', 'tip_refund']);
* the same event never produces a duplicate row. Topics are the canonical
* `_`-joined names decoded from the contract's topic tuples (see `decodeTopic`).
*/
const PROJECTIONS: Record<string, (event: DecodedEvent) => Promise<void>> = {
const PROJECTIONS: Record<string, (event: DecodedEvent, isNewEvent: boolean) => Promise<void>> = {
profile_register: projectProfileRegistered,
profile_updated: projectProfileUpdated,
goal_set: projectGoalSet,
Expand Down Expand Up @@ -49,7 +49,7 @@ export async function projectEvent(event: DecodedEvent): Promise<void> {

const handler = PROJECTIONS[event.topic];
if (handler) {
await handler(event);
await handler(event, isNewEvent);
}
if (REFUND_TOPICS.has(event.topic)) {
await projectRefund(event);
Expand Down Expand Up @@ -392,8 +392,12 @@ async function projectSubscriptionCreated(event: DecodedEvent): Promise<void> {
* Project a `("sub", "exec")` event — data `(subscriber, creator, amount)`. This
* confirms a successful recurring charge; the subscription is ensured ACTIVE and
* its charged amount recorded. Per-charge history is out of scope (no table).
*
* Notifies the creator of the charge, but only for genuinely new events —
* `isNewEvent` (from the event log) gates this, since the upsert itself is
* idempotent and would otherwise re-notify on every replay of the same ledgers.
*/
async function projectSubscriptionCharged(event: DecodedEvent): Promise<void> {
async function projectSubscriptionCharged(event: DecodedEvent, isNewEvent: boolean): Promise<void> {
const [subscriber, creator, amount] = tupleArgs(event.value);
const amountStroops = toBigInt(amount);
if (typeof subscriber !== 'string' || typeof creator !== 'string' || amountStroops === null) {
Expand All @@ -416,6 +420,17 @@ async function projectSubscriptionCharged(event: DecodedEvent): Promise<void> {
},
update: { amountStroops, status: 'ACTIVE' },
});

if (isNewEvent) {
try {
await notificationsService.createNotification(creatorId, 'subscription_charged', {
tipperId,
amountStroops: amountStroops.toString(),
});
} catch (err) {
logger.error({ err, creatorId }, 'Failed to notify creator of subscription charge');
}
}
}

/** Project a `("sub", "cancel")` event — data `(subscriber, creator)`. */
Expand Down
4 changes: 3 additions & 1 deletion backend/src/modules/notifications/notifications.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ const notificationPreferenceSchema = {
properties: {
tipReceived: { type: 'boolean', example: true },
goalReached: { type: 'boolean', example: true },
subscriptionCharged: { type: 'boolean', example: true },
updatedAt: { type: 'string', format: 'date-time' },
},
required: ['tipReceived', 'goalReached', 'updatedAt'],
required: ['tipReceived', 'goalReached', 'subscriptionCharged', 'updatedAt'],
};

mergeOpenApiPaths({
Expand Down Expand Up @@ -160,6 +161,7 @@ mergeOpenApiPaths({
properties: {
tipReceived: { type: 'boolean' },
goalReached: { type: 'boolean' },
subscriptionCharged: { type: 'boolean' },
},
},
},
Expand Down
1 change: 1 addition & 0 deletions backend/src/modules/notifications/notifications.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const updateNotificationPreferencesSchema = z
.object({
tipReceived: z.boolean().optional(),
goalReached: z.boolean().optional(),
subscriptionCharged: z.boolean().optional(),
})
.refine((data) => Object.keys(data).length > 0, {
message: 'At least one preference must be provided',
Expand Down
15 changes: 13 additions & 2 deletions backend/src/modules/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@ import type {
} from './notifications.types.js';

/** Maps a notification type to the preference field gating its delivery. */
const PREFERENCE_FIELD_BY_TYPE: Record<NotificationType, 'tipReceived' | 'goalReached'> = {
const PREFERENCE_FIELD_BY_TYPE: Record<
NotificationType,
'tipReceived' | 'goalReached' | 'subscriptionCharged'
> = {
tip_received: 'tipReceived',
goal_reached: 'goalReached',
subscription_charged: 'subscriptionCharged',
};

function formatNotification(n: {
Expand Down Expand Up @@ -122,11 +126,13 @@ export async function getUnreadCount(userId: string): Promise<UnreadCountRespons
function formatPreferences(pref: {
tipReceived: boolean;
goalReached: boolean;
subscriptionCharged: boolean;
updatedAt: Date;
}): NotificationPreferenceResponse {
return {
tipReceived: pref.tipReceived,
goalReached: pref.goalReached,
subscriptionCharged: pref.subscriptionCharged,
updatedAt: pref.updatedAt.toISOString(),
};
}
Expand All @@ -135,7 +141,12 @@ function formatPreferences(pref: {
export async function getPreferences(userId: string): Promise<NotificationPreferenceResponse> {
const pref = await prisma.notificationPreference.findUnique({ where: { userId } });
if (!pref) {
return { tipReceived: true, goalReached: true, updatedAt: new Date(0).toISOString() };
return {
tipReceived: true,
goalReached: true,
subscriptionCharged: true,
updatedAt: new Date(0).toISOString(),
};
}
return formatPreferences(pref);
}
Expand Down
74 changes: 73 additions & 1 deletion backend/src/modules/notifications/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,13 +395,15 @@ describe('getPreferences', () => {

expect(result.tipReceived).toBe(true);
expect(result.goalReached).toBe(true);
expect(result.subscriptionCharged).toBe(true);
});

it('returns the stored preference row when it exists', async () => {
const updatedAt = new Date('2026-07-24T12:00:00.000Z');
mockPrefFindUnique.mockResolvedValue({
tipReceived: false,
goalReached: true,
subscriptionCharged: false,
updatedAt,
});

Expand All @@ -410,6 +412,7 @@ describe('getPreferences', () => {
expect(result).toEqual({
tipReceived: false,
goalReached: true,
subscriptionCharged: false,
updatedAt: updatedAt.toISOString(),
});
});
Expand All @@ -422,7 +425,12 @@ describe('updatePreferences', () => {

it('upserts the preference row with the given patch', async () => {
const updatedAt = new Date('2026-07-25T12:00:00.000Z');
mockPrefUpsert.mockResolvedValue({ tipReceived: false, goalReached: true, updatedAt });
mockPrefUpsert.mockResolvedValue({
tipReceived: false,
goalReached: true,
subscriptionCharged: true,
updatedAt,
});

const result = await updatePreferences('user-1', { tipReceived: false });

Expand All @@ -433,6 +441,25 @@ describe('updatePreferences', () => {
update: { tipReceived: false },
});
});

it('upserts the subscriptionCharged preference', async () => {
const updatedAt = new Date('2026-07-25T12:00:00.000Z');
mockPrefUpsert.mockResolvedValue({
tipReceived: true,
goalReached: true,
subscriptionCharged: false,
updatedAt,
});

const result = await updatePreferences('user-1', { subscriptionCharged: false });

expect(result.subscriptionCharged).toBe(false);
expect(mockPrefUpsert).toHaveBeenCalledWith({
where: { userId: 'user-1' },
create: { userId: 'user-1', subscriptionCharged: false },
update: { subscriptionCharged: false },
});
});
});

describe('createNotification', () => {
Expand Down Expand Up @@ -492,6 +519,51 @@ describe('createNotification', () => {
expect(result).not.toBeNull();
expect(mockCreate).toHaveBeenCalled();
});

it('creates and emits a subscription_charged notification (#965)', async () => {
mockPrefFindUnique.mockResolvedValue(null);
const createdAt = new Date('2026-07-25T12:00:00.000Z');
mockCreate.mockResolvedValue({
id: 'notif-3',
type: 'subscription_charged',
payload: { tipperId: 'user-2', amountStroops: '500' },
readAt: null,
createdAt,
});

const result = await createNotification('user-1', 'subscription_charged', {
tipperId: 'user-2',
amountStroops: '500',
});

expect(result).not.toBeNull();
expect(mockCreate).toHaveBeenCalledWith({
data: {
userId: 'user-1',
type: 'subscription_charged',
payload: { tipperId: 'user-2', amountStroops: '500' },
},
});
expect(mockEmitNotificationCreated).toHaveBeenCalledWith(
expect.objectContaining({ id: 'notif-3', type: 'subscription_charged' }),
);
});

it('skips creation when the user disabled subscription_charged notifications', async () => {
mockPrefFindUnique.mockResolvedValue({
tipReceived: true,
goalReached: true,
subscriptionCharged: false,
});

const result = await createNotification('user-1', 'subscription_charged', {
tipperId: 'user-2',
amountStroops: '500',
});

expect(result).toBeNull();
expect(mockCreate).not.toHaveBeenCalled();
});
});

describe('GET /api/v1/notifications/unread-count', () => {
Expand Down
3 changes: 2 additions & 1 deletion backend/src/modules/notifications/notifications.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export interface NotificationListResponse {
}

/** Notification type discriminators used by the createNotification triggers. */
export type NotificationType = 'tip_received' | 'goal_reached';
export type NotificationType = 'tip_received' | 'goal_reached' | 'subscription_charged';

export interface UnreadCountResponse {
count: number;
Expand All @@ -26,5 +26,6 @@ export interface UnreadCountResponse {
export interface NotificationPreferenceResponse {
tipReceived: boolean;
goalReached: boolean;
subscriptionCharged: boolean;
updatedAt: string;
}
Loading
Loading