diff --git a/apps/api/.env.example b/apps/api/.env.example index a2c84f0b..77e01a70 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -186,3 +186,5 @@ OCI_LOGGING_MAX_BATCH_SIZE=50 OCI_LOGGING_FLUSH_INTERVAL_MS=5000 LOG_LEVEL=debug + +BUYMEACOFFEE_WEBHOOK_SECRET= \ No newline at end of file diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 74b64210..ab7a32b3 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -26,6 +26,7 @@ import { UserModule } from './user/user.module'; import { RedirectModule } from './redirect/redirect.module'; import { TeslaPublicKeyModule } from './tesla-public-key/tesla-public-key.module'; import { OnboardingModule } from './onboarding/onboarding.module'; +import { SupportersModule } from './supporters/supporters.module'; import { CloudflareThrottlerGuard } from '../common/guards/cloudflare-throttler.guard'; import { LogContextInterceptor } from '../common/interceptors/log-context.interceptor'; import { TokenRevokedExceptionFilter } from '../common/filters/token-revoked-exception.filter'; @@ -70,6 +71,7 @@ import { OffensiveResponseModule, AlertsModule, NotificationsModule, + SupportersModule, ThrottlerModule.forRoot([getThrottleConfig()]), ], controllers: [AppController, HealthController], diff --git a/apps/api/src/app/supporters/bmc-webhook-parser.util.spec.ts b/apps/api/src/app/supporters/bmc-webhook-parser.util.spec.ts new file mode 100644 index 00000000..4f9ca224 --- /dev/null +++ b/apps/api/src/app/supporters/bmc-webhook-parser.util.spec.ts @@ -0,0 +1,109 @@ +import * as crypto from 'crypto'; +import { SupporterType } from '../../entities/supporter.entity'; +import { + BmcWebhookPayload, + verifyWebhookSignature, +} from './bmc-webhook-parser.util'; + +describe('The bmc-webhook-parser utility', () => { + const originalSecret = process.env.BUYMEACOFFEE_WEBHOOK_SECRET; + + afterEach(() => { + process.env.BUYMEACOFFEE_WEBHOOK_SECRET = originalSecret; + }); + + describe('The verifyWebhookSignature() function', () => { + describe('When signature matches the HMAC of the raw body', () => { + it('should return true', () => { + const secret = 'test-secret-key-123'; + process.env.BUYMEACOFFEE_WEBHOOK_SECRET = secret; + const rawBody = JSON.stringify({ type: 'donation.created' }); + const signature = crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); + + expect(verifyWebhookSignature(rawBody, signature)).toBe(true); + }); + }); + + describe('When signature does not match', () => { + it('should return false', () => { + process.env.BUYMEACOFFEE_WEBHOOK_SECRET = 'secret'; + expect(verifyWebhookSignature('{"foo":"bar"}', 'invalid-hex-signature')).toBe(false); + }); + }); + }); + + describe('The BmcWebhookPayload class', () => { + describe('When instantiated with a donation payload', () => { + it('should transform into a complete active donation supporter entity', () => { + const payload = { + type: 'donation.created', + data: { + transaction_id: 'txn-123', + supporter_name: 'John Doe', + supporter_email: 'john@example.com', + coffee_count: 5, + support_note: 'Awesome project!', + support_created_on: '2024-01-01T12:00:00Z', + }, + }; + + const result = new BmcWebhookPayload(payload).toSupporter(); + + expect(result).toStrictEqual({ + external_id: 'txn-123', + name: 'John Doe', + email: 'john@example.com', + coffees: 5, + type: SupporterType.Donation, + is_active: true, + message: 'Awesome project!', + support_date: new Date('2024-01-01T12:00:00Z'), + }); + }); + }); + + describe('When instantiated with a membership payload', () => { + it('should transform into an active membership supporter entity with calculated coffees', () => { + const payload = { + type: 'membership.started', + data: { + psp_id: 'sub-456', + supporter_name: 'Alice', + amount: 5, + status: 'active', + started_at: 1719825600, + }, + }; + + const result = new BmcWebhookPayload(payload).toSupporter(); + + expect(result).toStrictEqual({ + external_id: 'sub-456', + name: 'Alice', + email: null, + coffees: 2, + type: SupporterType.Membership, + is_active: true, + message: null, + support_date: new Date(1719825600 * 1000), + }); + }); + }); + + describe('When instantiated with a cancellation or refund event', () => { + it('should mark isActive as false', () => { + const payload = { + type: 'membership.cancelled', + data: { + psp_id: 'sub-456', + status: 'canceled', + }, + }; + + const result = new BmcWebhookPayload(payload).toSupporter(); + + expect(result.is_active).toBe(false); + }); + }); + }); +}); diff --git a/apps/api/src/app/supporters/bmc-webhook-parser.util.ts b/apps/api/src/app/supporters/bmc-webhook-parser.util.ts new file mode 100644 index 00000000..86a07ae9 --- /dev/null +++ b/apps/api/src/app/supporters/bmc-webhook-parser.util.ts @@ -0,0 +1,157 @@ +import * as crypto from 'crypto'; +import { Supporter, SupporterType } from '../../entities/supporter.entity'; +import { + isPrivateSupporter, + sanitizeMessage, + sanitizeName, +} from './supporter-sanitizer.util'; + +export function verifyWebhookSignature(rawBody: string, signature?: string): boolean { + const secret = process.env.BUYMEACOFFEE_WEBHOOK_SECRET; + if (!secret || !signature) { + return false; + } + + const calculated = crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); + const signatureBuffer = Buffer.from(signature, 'hex'); + const calculatedBuffer = Buffer.from(calculated, 'hex'); + + if (signatureBuffer.length !== calculatedBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(signatureBuffer, calculatedBuffer); +} + +export class BmcWebhookPayload { + private readonly eventType: string; + private readonly data: Record; + private readonly isPrivate: boolean; + + constructor(payload: Record) { + this.eventType = String(payload['type'] || ''); + this.data = (payload['data'] || payload['response'] || payload) as Record; + this.isPrivate = isPrivateSupporter(this.data) || isPrivateSupporter(payload); + } + + public toSupporter(): Partial { + return { + external_id: this.resolveExternalId(), + name: this.resolveName(), + email: this.resolveEmail(), + coffees: this.resolveCoffees(), + type: this.resolveType(), + is_active: this.resolveIsActive(), + message: this.resolveMessage(), + support_date: this.resolveSupportDate(), + }; + } + + private resolveExternalId(): string | null { + const id = String( + this.data['transaction_id'] || + this.data['psp_id'] || + this.data['subscription_id'] || + this.data['id'] || + '' + ); + return id || null; + } + + private resolveName(): string { + const raw = String( + this.data['supporter_name'] || + this.data['payer_name'] || + this.data['name'] || + 'Anonymous' + ); + return sanitizeName(raw, this.isPrivate); + } + + private resolveEmail(): string | null { + const email = this.data['supporter_email'] || this.data['payer_email'] || this.data['email']; + return email ? String(email) : null; + } + + private resolveCoffees(): number { + const count = Number( + this.data['coffee_count'] || + this.data['number_of_coffees'] || + this.data['coffees'] || + this.data['quantity'] || + 0 + ); + const amount = Number( + this.data['amount'] || + this.data['total_amount_charged'] || + this.data['coffee_price'] || + 0 + ); + if (amount > 0) { + return Math.max(count, Math.max(1, Math.round(amount / 2.25))); + } + return Math.max(1, count); + } + + private resolveType(): SupporterType { + return this.isSubscription() ? SupporterType.Membership : SupporterType.Donation; + } + + private resolveIsActive(): boolean { + const isInactiveEvent = + this.eventType.endsWith('.refunded') || + this.eventType.endsWith('.cancelled') || + this.eventType.endsWith('.paused'); + const isInactiveStatus = + this.data['status'] === 'refunded' || + this.data['status'] === 'canceled' || + this.data['status'] === 'paused'; + const isInactiveFlag = + this.data['refunded'] === 'true' || + this.data['canceled'] === 'true' || + this.data['paused'] === 'true'; + + return !isInactiveEvent && !isInactiveStatus && !isInactiveFlag; + } + + private resolveMessage(): string | null { + const raw = this.data['support_note'] || this.data['message'] || null; + return sanitizeMessage(raw ? String(raw) : null, this.isPrivate) || null; + } + + private resolveSupportDate(): Date { + const raw = + this.data['support_created_on'] || + this.data['created_at'] || + this.data['started_at'] || + this.data['created']; + return this.parseDate(raw); + } + + private parseDate(raw: unknown): Date { + if (typeof raw === 'number') { + return raw < 1e11 ? new Date(raw * 1000) : new Date(raw); + } + if (typeof raw === 'string' && /^\d+$/.test(raw)) { + const num = parseInt(raw, 10); + return num < 1e11 ? new Date(num * 1000) : new Date(num); + } + if (typeof raw === 'string') { + return new Date(raw); + } + return new Date(); + } + + private isSubscription(): boolean { + return ( + this.eventType.startsWith('membership.') || + this.eventType.startsWith('recurring_donation.') || + this.eventType === 'subscription' || + Boolean( + this.data['subscription_id'] || + this.data['membership_level_id'] || + this.data['psp_id'] + ) + ); + } +} diff --git a/apps/api/src/app/supporters/supporter-aggregator.util.spec.ts b/apps/api/src/app/supporters/supporter-aggregator.util.spec.ts new file mode 100644 index 00000000..44328be9 --- /dev/null +++ b/apps/api/src/app/supporters/supporter-aggregator.util.spec.ts @@ -0,0 +1,73 @@ +import { Supporter, SupporterType } from '../../entities/supporter.entity'; +import { aggregateSupporters } from './supporter-aggregator.util'; + +describe('The supporter-aggregator utility', () => { + describe('The aggregateSupporters() function', () => { + describe('When aggregating multiple donations from same user', () => { + it('should sum coffee counts and use latest date', () => { + const item1: Supporter = { + id: '1', + name: 'Yvan', + email: 'yvan@example.com', + coffees: 5, + type: SupporterType.Donation, + is_active: true, + support_date: new Date('2024-01-01T10:00:00Z'), + created_at: new Date(), + updated_at: new Date(), + }; + + const item2: Supporter = { + id: '2', + name: 'Yvan M.', + email: 'yvan@example.com', + coffees: 10, + type: SupporterType.Donation, + is_active: true, + support_date: new Date('2024-01-05T10:00:00Z'), + created_at: new Date(), + updated_at: new Date(), + }; + + const result = aggregateSupporters([item1, item2]); + + expect(result).toHaveLength(1); + expect(result[0].coffees).toBe(15); + expect(result[0].name).toBe('Yvan M.'); + expect(result[0].supportDate).toBe('2024-01-05T10:00:00.000Z'); + }); + }); + + describe('When grouping anonymous contributors without email', () => { + it('should not merge separate anonymous contributors', () => { + const item1: Supporter = { + id: 'anon-1', + name: 'Anonymous', + coffees: 1, + type: SupporterType.Donation, + is_active: true, + support_date: new Date('2024-01-01T10:00:00Z'), + created_at: new Date(), + updated_at: new Date(), + }; + + const item2: Supporter = { + id: 'anon-2', + name: 'Someone', + coffees: 2, + type: SupporterType.Donation, + is_active: true, + support_date: new Date('2024-01-02T10:00:00Z'), + created_at: new Date(), + updated_at: new Date(), + }; + + const result = aggregateSupporters([item1, item2]); + + expect(result).toHaveLength(2); + expect(result[0].coffees).toBe(2); + expect(result[1].coffees).toBe(1); + }); + }); + }); +}); diff --git a/apps/api/src/app/supporters/supporter-aggregator.util.ts b/apps/api/src/app/supporters/supporter-aggregator.util.ts new file mode 100644 index 00000000..eb5dc86a --- /dev/null +++ b/apps/api/src/app/supporters/supporter-aggregator.util.ts @@ -0,0 +1,106 @@ +import { Supporter, SupporterType } from '../../entities/supporter.entity'; +import { sanitizeMessage, sanitizeName } from './supporter-sanitizer.util'; +import { PublicSupporterDto } from './supporters.service'; + +const ANONYMOUS_NAMES = ['someone', 'anonymous', 'supporter', 'anonyme']; + +export class SupporterAggregator { + constructor(private readonly items: Supporter[]) {} + + public aggregate(): PublicSupporterDto[] { + const groups = this.groupItems(); + + return Array.from(groups.values()) + .map((group) => this.mergeGroup(group)) + .sort((a, b) => this.compareSupporters(a, b)); + } + + private groupItems(): Map { + return this.items.reduce((groups, item) => { + const key = this.resolveKey(item); + const existing = groups.get(key) || []; + groups.set(key, [...existing, item]); + return groups; + }, new Map()); + } + + private compareSupporters(a: PublicSupporterDto, b: PublicSupporterDto): number { + const coffeeDiff = b.coffees - a.coffees; + if (coffeeDiff !== 0) { + return coffeeDiff; + } + + const subDiff = (b.isSubscriber ? 1 : 0) - (a.isSubscriber ? 1 : 0); + if (subDiff !== 0) { + return subDiff; + } + + return new Date(b.supportDate).getTime() - new Date(a.supportDate).getTime(); + } + + private mergeGroup(group: Supporter[]): PublicSupporterDto { + const sorted = [...group].sort((a, b) => b.support_date.getTime() - a.support_date.getTime()); + const latest = sorted[0]; + const isSubscriber = group.some((s) => s.type === SupporterType.Membership); + + return { + id: latest.id, + name: this.findBestName(sorted), + coffees: this.computeTotalCoffees(group, isSubscriber), + isSubscriber, + monthlyCoffees: this.computeMonthlyCoffees(group, isSubscriber), + supportDate: latest.support_date.toISOString(), + message: sanitizeMessage(sorted.find((s) => s.message?.trim())?.message), + }; + } + + private computeTotalCoffees(group: Supporter[], isSubscriber: boolean): number { + const donationSum = group + .filter((s) => s.type === SupporterType.Donation) + .reduce((acc, curr) => acc + curr.coffees, 0); + + if (donationSum > 0) { + return donationSum; + } + + return this.computeMonthlyCoffees(group, isSubscriber) || 1; + } + + private computeMonthlyCoffees(group: Supporter[], isSubscriber: boolean): number | undefined { + if (!isSubscriber) { + return undefined; + } + + return group + .filter((s) => s.type === SupporterType.Membership) + .reduce((acc, curr) => acc + curr.coffees, 0); + } + + private resolveKey(item: Supporter): string { + if (item.email?.trim()) { + return `email:${item.email.trim().toLowerCase()}`; + } + + const name = item.name.trim().toLowerCase(); + const isAnonymous = !name || ANONYMOUS_NAMES.includes(name); + + if (isAnonymous) { + return `id:${item.id}`; + } + + return `name:${name}`; + } + + private findBestName(sorted: Supporter[]): string { + const valid = sorted.find( + (s) => s.name && !ANONYMOUS_NAMES.includes(s.name.trim().toLowerCase()) + ); + + const chosen = valid || sorted[0]; + return sanitizeName(chosen.name); + } +} + +export function aggregateSupporters(items: Supporter[]): PublicSupporterDto[] { + return new SupporterAggregator(items).aggregate(); +} diff --git a/apps/api/src/app/supporters/supporter-sanitizer.util.spec.ts b/apps/api/src/app/supporters/supporter-sanitizer.util.spec.ts new file mode 100644 index 00000000..a8969988 --- /dev/null +++ b/apps/api/src/app/supporters/supporter-sanitizer.util.spec.ts @@ -0,0 +1,110 @@ +import { + isPrivateSupporter, + isProfaneOrSpam, + sanitizeMessage, + sanitizeName, +} from './supporter-sanitizer.util'; + +describe('The supporter-sanitizer utility', () => { + describe('The isProfaneOrSpam() function', () => { + describe('When text is clean', () => { + it('should return false', () => { + expect(isProfaneOrSpam('Alexandre')).toBe(false); + expect(isProfaneOrSpam('Merci pour votre super travail !')).toBe(false); + }); + }); + + describe('When text contains web links or domain names', () => { + it('should detect URLs and domain extensions', () => { + expect(isProfaneOrSpam('https://scam.com')).toBe(true); + expect(isProfaneOrSpam('visit my site www.crypto.io')).toBe(true); + expect(isProfaneOrSpam('join t.me/free_crypto')).toBe(true); + }); + }); + + describe('When text contains forbidden profanities', () => { + it('should return true for profanities regardless of accents and case', () => { + expect(isProfaneOrSpam('gros connard')).toBe(true); + expect(isProfaneOrSpam('espèce d\'enculé')).toBe(true); + expect(isProfaneOrSpam('FUCK THIS')).toBe(true); + }); + }); + }); + + describe('The sanitizeName() function', () => { + describe('When supporter is marked as private', () => { + it('should return Anonyme', () => { + expect(sanitizeName('John Doe', true)).toBe('Anonyme'); + }); + }); + + describe('When supporter name is generic, empty, or offensive', () => { + it('should return Anonyme', () => { + expect(sanitizeName(' ')).toBe('Anonyme'); + expect(sanitizeName('""')).toBe('Anonyme'); + expect(sanitizeName("''")).toBe('Anonyme'); + expect(sanitizeName('Someone')).toBe('Anonyme'); + expect(sanitizeName('Anonymous')).toBe('Anonyme'); + expect(sanitizeName('https://spam.com')).toBe('Anonyme'); + expect(sanitizeName('Hitler')).toBe('Anonyme'); + }); + }); + + describe('When supporter name is an email address', () => { + it('should return Anonyme to protect privacy', () => { + expect(sanitizeName('john.doe@example.com')).toBe('Anonyme'); + }); + }); + + describe('When supporter name is excessively long', () => { + it('should truncate to 30 characters', () => { + const longName = 'ThisIsAVeryLongSupporterNameThatExceedsTheLimit'; + const result = sanitizeName(longName); + expect(result.length).toBeLessThanOrEqual(30); + expect(result.endsWith('...')).toBe(true); + }); + }); + }); + + describe('The sanitizeMessage() function', () => { + describe('When message is private or contains spam/profanity', () => { + it('should return undefined', () => { + expect(sanitizeMessage('Nice app', true)).toBeUndefined(); + expect(sanitizeMessage('Visit https://gambling.com')).toBeUndefined(); + expect(sanitizeMessage('salope')).toBeUndefined(); + }); + }); + + describe('When message is clean', () => { + it('should return the trimmed message', () => { + expect(sanitizeMessage(' Bravo pour cette app ! ')).toBe('Bravo pour cette app !'); + }); + }); + + describe('When message is excessively long', () => { + it('should truncate to 120 characters', () => { + const longMsg = 'a'.repeat(200); + const result = sanitizeMessage(longMsg); + expect(result?.length).toBeLessThanOrEqual(120); + expect(result?.endsWith('...')).toBe(true); + }); + }); + }); + + describe('The isPrivateSupporter() function', () => { + describe('When data has private flags set to true', () => { + it('should return true', () => { + expect(isPrivateSupporter({ is_private: true })).toBe(true); + expect(isPrivateSupporter({ payer_is_private: 'true' })).toBe(true); + expect(isPrivateSupporter({ is_anonymous: '1' })).toBe(true); + }); + }); + + describe('When data has no private flags', () => { + it('should return false', () => { + expect(isPrivateSupporter({})).toBe(false); + expect(isPrivateSupporter({ is_private: false })).toBe(false); + }); + }); + }); +}); diff --git a/apps/api/src/app/supporters/supporter-sanitizer.util.ts b/apps/api/src/app/supporters/supporter-sanitizer.util.ts new file mode 100644 index 00000000..5ba917ca --- /dev/null +++ b/apps/api/src/app/supporters/supporter-sanitizer.util.ts @@ -0,0 +1,87 @@ +const SPAM_OR_LINK_REGEX = + /(https?:\/\/|www\.|t\.me\/|discord\.gg\/|0x[a-fA-F0-9]{20,}|\.(com|org|net|io|xyz|ru|cn|top|app|dev|link|me|vip)\b)/i; + +const PROFANITIES = [ + 'hitler', + 'nazi', + 'nigger', + 'nigga', + 'faggot', + 'pedophile', + 'putain', + 'salope', + 'connard', + 'encule', + 'enculé', + 'bitch', + 'whore', + 'cunt', + 'dick', + 'fuck', +]; + +export function isProfaneOrSpam(text: string): boolean { + if (!text) { + return false; + } + + if (SPAM_OR_LINK_REGEX.test(text)) { + return true; + } + + const normalized = text + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, ''); + + return PROFANITIES.some((word) => normalized.includes(word)); +} + +export function sanitizeName(rawName?: string | null, isPrivate = false): string { + if (isPrivate || !rawName) { + return 'Anonyme'; + } + + const trimmed = rawName.replace(/^["']|["']$/g, '').trim(); + + if ( + !trimmed || + trimmed.includes('@') || + isProfaneOrSpam(trimmed) || + trimmed.toLowerCase() === 'someone' || + trimmed.toLowerCase() === 'anonymous' || + trimmed.toLowerCase() === 'anonyme' || + trimmed.toLowerCase() === 'supporter' + ) { + return 'Anonyme'; + } + + return trimmed.length > 30 ? `${trimmed.slice(0, 27)}...` : trimmed; +} + +export function sanitizeMessage(rawMessage?: string | null, isPrivate = false): string | undefined { + if (isPrivate || !rawMessage) { + return undefined; + } + + const trimmed = rawMessage.replace(/^["']|["']$/g, '').trim(); + if (!trimmed || isProfaneOrSpam(trimmed)) { + return undefined; + } + + return trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed; +} + +export function isPrivateSupporter(data: Record): boolean { + const flags = [ + data['is_private'], + data['payer_is_private'], + data['is_anonymous'], + data['is_hidden'], + data['private'], + ]; + + return flags.some( + (flag) => flag === true || flag === 'true' || flag === '1' || flag === 1 + ); +} diff --git a/apps/api/src/app/supporters/supporters.controller.spec.ts b/apps/api/src/app/supporters/supporters.controller.spec.ts new file mode 100644 index 00000000..0eabfae6 --- /dev/null +++ b/apps/api/src/app/supporters/supporters.controller.spec.ts @@ -0,0 +1,58 @@ +import { mock, MockProxy } from 'jest-mock-extended'; +import { SupportersController } from './supporters.controller'; +import { PublicSupportersResponse, SupportersService } from './supporters.service'; + +describe('The SupportersController class', () => { + let controller: SupportersController; + let supportersService: MockProxy; + + beforeEach(() => { + supportersService = mock(); + controller = new SupportersController(supportersService); + }); + + describe('The getSupporters() method', () => { + describe('When called by client', () => { + it('should delegate to supportersService.getPublicSupporters()', async () => { + const mockResponse: PublicSupportersResponse = { + subscribers: [], + supporters: [ + { + id: '1', + name: 'John', + coffees: 1, + supportDate: '2024-01-01', + }, + ], + totalCoffeesCount: 1, + hasActiveSupporters: true, + }; + + supportersService.getPublicSupporters.mockResolvedValue(mockResponse); + + const result = await controller.getSupporters(); + + expect(result).toStrictEqual(mockResponse); + expect(supportersService.getPublicSupporters).toHaveBeenCalled(); + }); + }); + }); + + describe('The handleWebhook() method', () => { + describe('When a webhook payload is received', () => { + it('should pass payload and signature to supportersService and return success', async () => { + const payload = { test: 'data' }; + const signature = 'test-signature'; + + const result = await controller.handleWebhook(payload, signature); + + expect(result).toStrictEqual({ success: true }); + expect(supportersService.handleWebhook).toHaveBeenCalledWith( + payload, + JSON.stringify(payload), + signature + ); + }); + }); + }); +}); diff --git a/apps/api/src/app/supporters/supporters.controller.ts b/apps/api/src/app/supporters/supporters.controller.ts new file mode 100644 index 00000000..0b20ac35 --- /dev/null +++ b/apps/api/src/app/supporters/supporters.controller.ts @@ -0,0 +1,25 @@ +import { Body, Controller, Get, Headers, HttpCode, HttpStatus, Post, type RawBodyRequest, Req } from '@nestjs/common'; +import { Request } from 'express'; +import { PublicSupportersResponse, SupportersService } from './supporters.service'; + +@Controller('supporters') +export class SupportersController { + constructor(private readonly supportersService: SupportersService) {} + + @Get() + public async getSupporters(): Promise { + return this.supportersService.getPublicSupporters(); + } + + @Post('webhook') + @HttpCode(HttpStatus.OK) + public async handleWebhook( + @Body() payload: Record, + @Headers('x-signature-sha256') signature: string, + @Req() req?: RawBodyRequest + ): Promise<{ success: boolean }> { + const rawBody = req?.rawBody ? req.rawBody.toString('utf-8') : JSON.stringify(payload); + await this.supportersService.handleWebhook(payload, rawBody, signature); + return { success: true }; + } +} diff --git a/apps/api/src/app/supporters/supporters.module.ts b/apps/api/src/app/supporters/supporters.module.ts new file mode 100644 index 00000000..2ff963a6 --- /dev/null +++ b/apps/api/src/app/supporters/supporters.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Supporter } from '../../entities/supporter.entity'; +import { SupportersController } from './supporters.controller'; +import { SupportersService } from './supporters.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Supporter])], + controllers: [SupportersController], + providers: [SupportersService], + exports: [SupportersService], +}) +export class SupportersModule {} diff --git a/apps/api/src/app/supporters/supporters.service.spec.ts b/apps/api/src/app/supporters/supporters.service.spec.ts new file mode 100644 index 00000000..6279dc8a --- /dev/null +++ b/apps/api/src/app/supporters/supporters.service.spec.ts @@ -0,0 +1,419 @@ +import * as crypto from 'crypto'; +import { UnauthorizedException } from '@nestjs/common'; +import { mock, MockProxy } from 'jest-mock-extended'; +import { Repository } from 'typeorm'; +import { Supporter, SupporterType } from '../../entities/supporter.entity'; +import { SupportersService } from './supporters.service'; + +describe('The SupportersService class', () => { + let service: SupportersService; + let supporterRepository: MockProxy>; + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + supporterRepository = mock>(); + service = new SupportersService(supporterRepository); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('The getPublicSupporters() method', () => { + describe('When supporters and subscribers exist in the database', () => { + it('should partition and format public subscribers and supporters properly', async () => { + const mockSupporter1: Supporter = { + id: 'sub-1', + external_id: 'ext-sub-1', + name: 'Alice', + email: 'alice@example.com', + coffees: 2, + type: SupporterType.Membership, + is_active: true, + message: null, + support_date: new Date('2024-01-01T10:00:00Z'), + created_at: new Date('2024-01-01T10:00:00Z'), + updated_at: new Date('2024-01-01T10:00:00Z'), + }; + + const mockSupporter2: Supporter = { + id: 'don-1', + external_id: 'ext-don-1', + name: 'Bob', + email: 'bob@example.com', + coffees: 3, + type: SupporterType.Donation, + is_active: true, + message: 'Keep up the great work!', + support_date: new Date('2024-01-02T10:00:00Z'), + created_at: new Date('2024-01-02T10:00:00Z'), + updated_at: new Date('2024-01-02T10:00:00Z'), + }; + + supporterRepository.find.mockResolvedValue([mockSupporter1, mockSupporter2]); + + const result = await service.getPublicSupporters(); + + expect(result).toStrictEqual({ + subscribers: [], + supporters: [ + { + id: 'don-1', + name: 'Bob', + coffees: 3, + isSubscriber: false, + monthlyCoffees: undefined, + supportDate: '2024-01-02T10:00:00.000Z', + message: 'Keep up the great work!', + }, + { + id: 'sub-1', + name: 'Alice', + coffees: 2, + isSubscriber: true, + monthlyCoffees: 2, + supportDate: '2024-01-01T10:00:00.000Z', + message: undefined, + }, + ], + totalCoffeesCount: 5, + hasActiveSupporters: true, + }); + }); + }); + + describe('When a user has made multiple separate donations', () => { + it('should aggregate donations by email and sum the coffee counts', async () => { + const donation1: Supporter = { + id: 'don-1', + name: 'Yvan', + email: 'yvan@example.com', + coffees: 10, + type: SupporterType.Donation, + is_active: true, + support_date: new Date('2024-01-01T10:00:00Z'), + created_at: new Date(), + updated_at: new Date(), + }; + + const donation2: Supporter = { + id: 'don-2', + name: 'Yvan', + email: 'yvan@example.com', + coffees: 25, + type: SupporterType.Donation, + is_active: true, + support_date: new Date('2024-02-01T10:00:00Z'), + created_at: new Date(), + updated_at: new Date(), + }; + + supporterRepository.find.mockResolvedValue([donation1, donation2]); + + const result = await service.getPublicSupporters(); + + expect(result.supporters).toStrictEqual([ + { + id: 'don-2', + name: 'Yvan', + coffees: 35, + isSubscriber: false, + monthlyCoffees: undefined, + supportDate: '2024-02-01T10:00:00.000Z', + message: undefined, + }, + ]); + }); + }); + + describe('When the database has no records', () => { + it('should return empty collections with hasActiveSupporters set to false', async () => { + supporterRepository.find.mockResolvedValue([]); + + const result = await service.getPublicSupporters(); + + expect(result).toStrictEqual({ + subscribers: [], + supporters: [], + totalCoffeesCount: 0, + hasActiveSupporters: false, + }); + }); + }); + }); + + describe('The handleWebhook() method', () => { + const testSecret = 'test-signing-secret'; + + function signPayload(payload: Record): { rawBody: string; signature: string } { + process.env.BUYMEACOFFEE_WEBHOOK_SECRET = testSecret; + const rawBody = JSON.stringify(payload); + const signature = crypto.createHmac('sha256', testSecret).update(rawBody).digest('hex'); + return { rawBody, signature }; + } + + describe('When a valid donation.created event is received', () => { + it('should parse and persist the new donation with active status', async () => { + const payload = { + type: 'donation.created', + data: { + transaction_id: 'pi_12345', + supporter_name: 'Charlie', + supporter_email: 'charlie@example.com', + coffee_count: 5, + support_note: 'Awesome project!', + created_at: 1719825600, + }, + }; + const { rawBody, signature } = signPayload(payload); + + supporterRepository.findOne.mockResolvedValue(null); + supporterRepository.create.mockImplementation((data) => data as Supporter); + supporterRepository.save.mockImplementation(async (data) => data as Supporter); + + const result = await service.handleWebhook(payload, rawBody, signature); + + expect(result.name).toBe('Charlie'); + expect(result.coffees).toBe(5); + expect(result.type).toBe(SupporterType.Donation); + expect(result.is_active).toBe(true); + expect(result.message).toBe('Awesome project!'); + expect(result.support_date).toStrictEqual(new Date(1719825600 * 1000)); + expect(supporterRepository.save).toHaveBeenCalled(); + }); + }); + + describe('When a donation.refunded event is received', () => { + it('should mark the donation as inactive', async () => { + const payload = { + type: 'donation.refunded', + data: { + transaction_id: 'pi_12345', + supporter_name: 'Charlie', + status: 'refunded', + refunded: 'true', + }, + }; + const { rawBody, signature } = signPayload(payload); + + const existing: Supporter = { + id: 'don-1', + external_id: 'pi_12345', + name: 'Charlie', + coffees: 5, + type: SupporterType.Donation, + is_active: true, + support_date: new Date(), + created_at: new Date(), + updated_at: new Date(), + }; + + supporterRepository.findOne.mockResolvedValue(existing); + supporterRepository.save.mockImplementation(async (data) => data as Supporter); + + const result = await service.handleWebhook(payload, rawBody, signature); + + expect(result.is_active).toBe(false); + expect(supporterRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ external_id: 'pi_12345', is_active: false }) + ); + }); + }); + + describe('When a membership.started event is received', () => { + it('should parse and persist the membership properly', async () => { + const payload = { + type: 'membership.started', + data: { + psp_id: 'sub_999', + supporter_name: 'David', + supporter_email: 'david@example.com', + amount: 5, + status: 'active', + started_at: 1719825600, + }, + }; + const { rawBody, signature } = signPayload(payload); + + supporterRepository.findOne.mockResolvedValue(null); + supporterRepository.create.mockImplementation((data) => data as Supporter); + supporterRepository.save.mockImplementation(async (data) => data as Supporter); + + const result = await service.handleWebhook(payload, rawBody, signature); + + expect(result.name).toBe('David'); + expect(result.type).toBe(SupporterType.Membership); + expect(result.is_active).toBe(true); + expect(result.external_id).toBe('sub_999'); + }); + }); + + describe('When a membership.cancelled event is received', () => { + it('should mark the membership as inactive', async () => { + const payload = { + type: 'membership.cancelled', + data: { + psp_id: 'sub_999', + supporter_name: 'David', + status: 'canceled', + canceled: 'true', + }, + }; + const { rawBody, signature } = signPayload(payload); + + supporterRepository.findOne.mockResolvedValue(null); + supporterRepository.create.mockImplementation((data) => data as Supporter); + supporterRepository.save.mockImplementation(async (data) => data as Supporter); + + const result = await service.handleWebhook(payload, rawBody, signature); + + expect(result.is_active).toBe(false); + }); + }); + + describe('When a membership webhook arrives for an existing CSV-imported member without external_id', () => { + it('should reconcile by email and update the existing record with the new external_id', async () => { + const existingCsvMember: Supporter = { + id: 'csv-member-uuid', + external_id: null, + name: 'David', + email: 'david@example.com', + coffees: 1, + type: SupporterType.Membership, + is_active: true, + support_date: new Date('2024-01-01T00:00:00Z'), + created_at: new Date('2024-01-01T00:00:00Z'), + updated_at: new Date('2024-01-01T00:00:00Z'), + }; + + const payload = { + type: 'membership.cancelled', + data: { + psp_id: 'sub_new_bmc_id', + supporter_email: 'david@example.com', + status: 'canceled', + }, + }; + const { rawBody, signature } = signPayload(payload); + + supporterRepository.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(existingCsvMember); + supporterRepository.save.mockImplementation(async (data) => data as Supporter); + + const result = await service.handleWebhook(payload, rawBody, signature); + + expect(result.id).toBe('csv-member-uuid'); + expect(result.external_id).toBe('sub_new_bmc_id'); + expect(result.is_active).toBe(false); + }); + }); + + describe('When a membership.paused event is received', () => { + it('should mark the membership as inactive', async () => { + const payload = { + type: 'membership.paused', + data: { + psp_id: 'sub_999', + status: 'paused', + paused: 'true', + }, + }; + const { rawBody, signature } = signPayload(payload); + + supporterRepository.findOne.mockResolvedValue(null); + supporterRepository.create.mockImplementation((data) => data as Supporter); + supporterRepository.save.mockImplementation(async (data) => data as Supporter); + + const result = await service.handleWebhook(payload, rawBody, signature); + + expect(result.is_active).toBe(false); + }); + }); + + describe('When a recurring_donation.started event is received', () => { + it('should categorize the recurring donation as Membership', async () => { + const payload = { + type: 'recurring_donation.started', + data: { + psp_id: 'sub_888', + supporter_name: 'Emma', + status: 'active', + }, + }; + const { rawBody, signature } = signPayload(payload); + + supporterRepository.findOne.mockResolvedValue(null); + supporterRepository.create.mockImplementation((data) => data as Supporter); + supporterRepository.save.mockImplementation(async (data) => data as Supporter); + + const result = await service.handleWebhook(payload, rawBody, signature); + + expect(result.type).toBe(SupporterType.Membership); + expect(result.is_active).toBe(true); + }); + }); + + describe('When a recurring_donation.cancelled event is received', () => { + it('should mark the recurring donation as inactive', async () => { + const payload = { + type: 'recurring_donation.cancelled', + data: { + psp_id: 'sub_888', + status: 'canceled', + }, + }; + const { rawBody, signature } = signPayload(payload); + + supporterRepository.findOne.mockResolvedValue(null); + supporterRepository.create.mockImplementation((data) => data as Supporter); + supporterRepository.save.mockImplementation(async (data) => data as Supporter); + + const result = await service.handleWebhook(payload, rawBody, signature); + + expect(result.is_active).toBe(false); + }); + }); + + describe('When an extra_purchase.refunded event is received', () => { + it('should mark the extra purchase as inactive', async () => { + const payload = { + type: 'extra_purchase.refunded', + data: { + transaction_id: 'pi_extra_1', + status: 'refunded', + }, + }; + const { rawBody, signature } = signPayload(payload); + + supporterRepository.findOne.mockResolvedValue(null); + supporterRepository.create.mockImplementation((data) => data as Supporter); + supporterRepository.save.mockImplementation(async (data) => data as Supporter); + + const result = await service.handleWebhook(payload, rawBody, signature); + + expect(result.is_active).toBe(false); + }); + }); + + describe('When webhook signature is invalid or missing', () => { + it('should throw an UnauthorizedException when signature is invalid', async () => { + process.env.BUYMEACOFFEE_WEBHOOK_SECRET = 'secret'; + + await expect( + service.handleWebhook({ test: true }, '{"test":true}', 'invalid_sig') + ).rejects.toThrow(UnauthorizedException); + }); + + it('should throw an UnauthorizedException when signature is missing', async () => { + process.env.BUYMEACOFFEE_WEBHOOK_SECRET = 'secret'; + + await expect( + service.handleWebhook({ test: true }, '{"test":true}', undefined) + ).rejects.toThrow(UnauthorizedException); + }); + }); + }); +}); + diff --git a/apps/api/src/app/supporters/supporters.service.ts b/apps/api/src/app/supporters/supporters.service.ts new file mode 100644 index 00000000..72d42c81 --- /dev/null +++ b/apps/api/src/app/supporters/supporters.service.ts @@ -0,0 +1,104 @@ +import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Supporter, SupporterType } from '../../entities/supporter.entity'; +import { BmcWebhookPayload, verifyWebhookSignature } from './bmc-webhook-parser.util'; +import { aggregateSupporters } from './supporter-aggregator.util'; + +export interface PublicSupporterDto { + id: string; + name: string; + coffees: number; + isSubscriber?: boolean; + monthlyCoffees?: number; + supportDate: string; + message?: string; +} + +export interface PublicSubscriberDto { + id: string; + name: string; + durationType: string; + membershipDate: string; + coffees: number; +} + +export interface PublicSupportersResponse { + subscribers: PublicSubscriberDto[]; + supporters: PublicSupporterDto[]; + totalCoffeesCount: number; + hasActiveSupporters: boolean; +} + +@Injectable() +export class SupportersService { + private readonly logger = new Logger(SupportersService.name); + + constructor( + @InjectRepository(Supporter) + private readonly supporterRepository: Repository + ) {} + + public async getPublicSupporters(): Promise { + const activeItems = await this.supporterRepository.find({ + where: { is_active: true }, + order: { support_date: 'DESC' }, + }); + const totalCoffeesCount = activeItems.reduce((acc, item) => acc + item.coffees, 0); + const unifiedSupporters = aggregateSupporters(activeItems); + + return { + subscribers: [], + supporters: unifiedSupporters, + totalCoffeesCount, + hasActiveSupporters: unifiedSupporters.length > 0, + }; + } + + public async handleWebhook( + payload: Record, + rawBody?: string, + signature?: string + ): Promise { + if (!rawBody || !signature || !verifyWebhookSignature(rawBody, signature)) { + this.logger.warn('Received invalid or missing Buy Me a Coffee webhook signature'); + throw new UnauthorizedException('Invalid webhook signature'); + } + + const supporterData = new BmcWebhookPayload(payload).toSupporter(); + return this.upsertSupporter(supporterData); + } + + + private async upsertSupporter(data: Partial): Promise { + const existing = await this.findExistingSupporter(data); + if (existing) { + Object.assign(existing, data); + return this.supporterRepository.save(existing); + } + + const created = this.supporterRepository.create(data); + return this.supporterRepository.save(created); + } + + private async findExistingSupporter(data: Partial): Promise { + if (data.external_id) { + const byExternalId = await this.supporterRepository.findOne({ + where: { external_id: data.external_id }, + }); + + if (byExternalId) { + return byExternalId; + } + } + + if (data.email && data.type === SupporterType.Membership) { + return this.supporterRepository.findOne({ + where: { email: data.email, type: SupporterType.Membership }, + order: { support_date: 'DESC' }, + }); + } + + return null; + } +} diff --git a/apps/api/src/config/database.config.ts b/apps/api/src/config/database.config.ts index 9c11d0db..f8fd5785 100644 --- a/apps/api/src/config/database.config.ts +++ b/apps/api/src/config/database.config.ts @@ -11,6 +11,7 @@ import { NotificationPreferences } from '../entities/notification-preferences.en import { PushDeviceToken } from '../entities/push-device-token.entity'; import { AlertEvent } from '../entities/alert-event.entity'; import { UserSession } from '../entities/user-session.entity'; +import { Supporter } from '../entities/supporter.entity'; const isProduction = process.env.NODE_ENV === 'production'; @@ -65,7 +66,7 @@ export const getDatabaseConfig = (): TypeOrmModuleOptions => { username: databaseUser, password: databasePassword, database: databaseName, - entities: [User, Vehicle, TelegramConfig, UserConsent, Waitlist, FeatureAnnouncement, UserDismissedAnnouncement, NotificationPreferences, PushDeviceToken, AlertEvent, UserSession], + entities: [User, Vehicle, TelegramConfig, UserConsent, Waitlist, FeatureAnnouncement, UserDismissedAnnouncement, NotificationPreferences, PushDeviceToken, AlertEvent, UserSession, Supporter], synchronize, migrationsRun, migrations: ['dist/migrations/*.js'], diff --git a/apps/api/src/entities/supporter.entity.spec.ts b/apps/api/src/entities/supporter.entity.spec.ts new file mode 100644 index 00000000..ba02a2d2 --- /dev/null +++ b/apps/api/src/entities/supporter.entity.spec.ts @@ -0,0 +1,12 @@ +import { getMetadataArgsStorage } from 'typeorm'; +import { Supporter } from './supporter.entity'; + +describe('The Supporter entity', () => { + it('should define an index on type and is_active columns', () => { + const index = getMetadataArgsStorage().indices.find( + (metadata) => metadata.target === Supporter + ); + + expect(index?.columns).toStrictEqual(['type', 'is_active']); + }); +}); diff --git a/apps/api/src/entities/supporter.entity.ts b/apps/api/src/entities/supporter.entity.ts new file mode 100644 index 00000000..f294aa02 --- /dev/null +++ b/apps/api/src/entities/supporter.entity.ts @@ -0,0 +1,43 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; + +export enum SupporterType { + Donation = 'donation', + Membership = 'membership', +} + +@Entity('supporters') +@Index(['type', 'is_active']) +export class Supporter { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 255, nullable: true, unique: true }) + external_id?: string | null; + + @Column({ type: 'varchar', length: 255 }) + name!: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + email?: string | null; + + @Column({ type: 'int', default: 1 }) + coffees!: number; + + @Column({ type: 'enum', enum: SupporterType, default: SupporterType.Donation }) + type!: SupporterType; + + @Column({ type: 'boolean', default: true }) + is_active!: boolean; + + @Column({ type: 'text', nullable: true }) + message?: string | null; + + @Column({ type: 'timestamp with time zone' }) + support_date!: Date; + + @CreateDateColumn() + created_at!: Date; + + @UpdateDateColumn() + updated_at!: Date; +} diff --git a/apps/api/src/migrations/1783000000000-CreateSupportersTable.ts b/apps/api/src/migrations/1783000000000-CreateSupportersTable.ts new file mode 100644 index 00000000..e6e89748 --- /dev/null +++ b/apps/api/src/migrations/1783000000000-CreateSupportersTable.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateSupportersTable1783000000000 implements MigrationInterface { + name = 'CreateSupportersTable1783000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "public"."supporters_type_enum" AS ENUM('donation', 'membership')` + ); + await queryRunner.query( + `CREATE TABLE "supporters" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "external_id" character varying(255), + "name" character varying(255) NOT NULL, + "email" character varying(255), + "coffees" integer NOT NULL DEFAULT 1, + "type" "public"."supporters_type_enum" NOT NULL DEFAULT 'donation', + "is_active" boolean NOT NULL DEFAULT true, + "message" text, + "support_date" TIMESTAMP WITH TIME ZONE NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "UQ_supporters_external_id" UNIQUE ("external_id"), + CONSTRAINT "PK_supporters_id" PRIMARY KEY ("id") + )` + ); + await queryRunner.query( + `CREATE INDEX "IDX_supporters_type_is_active" ON "supporters" ("type", "is_active")` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_supporters_type_is_active"`); + await queryRunner.query(`DROP TABLE "supporters"`); + await queryRunner.query(`DROP TYPE "public"."supporters_type_enum"`); + } +} diff --git a/apps/mobile/src/core/navigation.ts b/apps/mobile/src/core/navigation.ts index f3ec37ae..df84419b 100644 --- a/apps/mobile/src/core/navigation.ts +++ b/apps/mobile/src/core/navigation.ts @@ -3,6 +3,7 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack'; export enum AppTab { Dashboard = 'dashboard', Alerts = 'alerts', + Supporters = 'supporters', Settings = 'settings', } @@ -22,6 +23,7 @@ export type MainStackParamList = { export type AppTabParamList = { Dashboard: undefined; Alerts: undefined; + Supporters: undefined; Settings: undefined; }; diff --git a/apps/mobile/src/core/shell/MainScreen.tsx b/apps/mobile/src/core/shell/MainScreen.tsx index 0d94c63a..8edb526f 100644 --- a/apps/mobile/src/core/shell/MainScreen.tsx +++ b/apps/mobile/src/core/shell/MainScreen.tsx @@ -18,6 +18,7 @@ import { DashboardScreen } from '../../screens/DashboardScreen'; import { DeleteAccountScreen } from '../../screens/DeleteAccountScreen'; import { OnboardingScreen } from '../../screens/OnboardingScreen'; import { SettingsScreen } from '../../screens/SettingsScreen'; +import { SupportersScreen } from '../../screens/SupportersScreen'; import { TelegramSettingsScreen } from '../../screens/TelegramSettingsScreen'; import { VehicleDetailScreen } from '../../screens/VehicleDetailScreen'; @@ -98,6 +99,14 @@ function AppTabs({ onLogout }: { onLogout(): Promise }): JSX.Element { tabBarIcon: ({ color, size }) => , }} /> + , + }} + /> = { 'bubble.left.and.bubble.right.fill': 'chatbubbles', 'checkmark.shield.fill': 'shield-checkmark', 'chevron.right': 'chevron-forward', + 'crown.fill': 'trophy', 'cup.and.saucer.fill': 'cafe', 'doc.text.fill': 'document-text', 'envelope.fill': 'mail', 'exclamationmark.shield.fill': 'shield-half', 'exclamationmark.triangle.fill': 'warning', 'gearshape.fill': 'settings', + 'heart.fill': 'heart', 'key.fill': 'key', 'person.2.fill': 'people', 'person.fill': 'person', diff --git a/apps/mobile/src/features/supporters/data/supporters.api-repository.ts b/apps/mobile/src/features/supporters/data/supporters.api-repository.ts new file mode 100644 index 00000000..0c0ef2fd --- /dev/null +++ b/apps/mobile/src/features/supporters/data/supporters.api-repository.ts @@ -0,0 +1,11 @@ +import { ApiClientRequirements } from '../../../core/api/api-client'; +import { SupportersData } from '../domain/entities'; +import { SupportersRepositoryRequirements } from '../domain/supporters.repository.requirements'; + +export class SupportersApiRepository implements SupportersRepositoryRequirements { + public constructor(private readonly client: ApiClientRequirements) {} + + public async getSupporters(): Promise { + return this.client.request('/supporters'); + } +} diff --git a/apps/mobile/src/features/supporters/data/supporters.mock-repository.ts b/apps/mobile/src/features/supporters/data/supporters.mock-repository.ts new file mode 100644 index 00000000..89f80c68 --- /dev/null +++ b/apps/mobile/src/features/supporters/data/supporters.mock-repository.ts @@ -0,0 +1,71 @@ +import { SupportersData } from '../domain/entities'; +import { SupportersRepositoryRequirements } from '../domain/supporters.repository.requirements'; + +export class SupportersMockRepository implements SupportersRepositoryRequirements { + public async getSupporters(): Promise { + return { + hasActiveSupporters: true, + subscribers: [], + supporters: [ + { + coffees: 35, + id: 'mock-don-1', + isSubscriber: true, + monthlyCoffees: 1, + name: 'Alexandre D.', + supportDate: '2026-05-29T00:00:00.000Z', + }, + { + coffees: 10, + id: 'mock-don-2', + name: 'TeslaFan92', + supportDate: '2026-07-01T00:00:00.000Z', + }, + { + coffees: 10, + id: 'mock-don-3', + isSubscriber: true, + monthlyCoffees: 1, + name: 'Sophie M.', + supportDate: '2026-06-15T00:00:00.000Z', + }, + { + coffees: 8, + id: 'mock-don-4', + message: 'Merci pour ce travail exceptionnel sur le mode Sentinelle !', + name: 'Julien T.', + supportDate: '2026-08-04T00:00:00.000Z', + }, + { + coffees: 8, + id: 'mock-don-5', + name: 'CyberDriver', + supportDate: '2026-07-02T00:00:00.000Z', + }, + { + coffees: 5, + id: 'mock-don-6', + name: 'Maxime B.', + supportDate: '2026-08-14T00:00:00.000Z', + }, + { + coffees: 3, + id: 'mock-don-7', + isSubscriber: true, + monthlyCoffees: 3, + name: 'Lucas R.', + supportDate: '2026-08-26T00:00:00.000Z', + }, + { + coffees: 1, + id: 'mock-don-8', + isSubscriber: true, + monthlyCoffees: 1, + name: 'Clara P.', + supportDate: '2026-08-25T00:00:00.000Z', + }, + ], + totalCoffeesCount: 78, + }; + } +} diff --git a/apps/mobile/src/features/supporters/di.ts b/apps/mobile/src/features/supporters/di.ts new file mode 100644 index 00000000..77e6dbd9 --- /dev/null +++ b/apps/mobile/src/features/supporters/di.ts @@ -0,0 +1,28 @@ +import { apiClient, tokenStore } from '../../core/api'; +import { SupportersApiRepository } from './data/supporters.api-repository'; +import { SupportersMockRepository } from './data/supporters.mock-repository'; +import { SupportersData } from './domain/entities'; +import { SupportersRepositoryRequirements } from './domain/supporters.repository.requirements'; +import { GetSupportersUseCase } from './domain/use-cases/get-supporters.use-case'; + +class DynamicSupportersRepository implements SupportersRepositoryRequirements { + public constructor( + private readonly apiRepo: SupportersRepositoryRequirements, + private readonly mockRepo: SupportersRepositoryRequirements + ) {} + + private getRepo(): SupportersRepositoryRequirements { + return tokenStore.isDemo() ? this.mockRepo : this.apiRepo; + } + + public async getSupporters(): Promise { + return this.getRepo().getSupporters(); + } +} + +export const supportersRepository = new DynamicSupportersRepository( + new SupportersApiRepository(apiClient), + new SupportersMockRepository() +); + +export const getSupportersUseCase = new GetSupportersUseCase(supportersRepository); diff --git a/apps/mobile/src/features/supporters/domain/entities.ts b/apps/mobile/src/features/supporters/domain/entities.ts new file mode 100644 index 00000000..84c32647 --- /dev/null +++ b/apps/mobile/src/features/supporters/domain/entities.ts @@ -0,0 +1,24 @@ +export interface Supporter { + coffees: number; + id: string; + isSubscriber?: boolean; + message?: string; + monthlyCoffees?: number; + name: string; + supportDate: string; +} + +export interface Subscriber { + coffees: number; + durationType: string; + id: string; + membershipDate: string; + name: string; +} + +export interface SupportersData { + hasActiveSupporters: boolean; + subscribers: Subscriber[]; + supporters: Supporter[]; + totalCoffeesCount: number; +} diff --git a/apps/mobile/src/features/supporters/domain/supporters.repository.requirements.ts b/apps/mobile/src/features/supporters/domain/supporters.repository.requirements.ts new file mode 100644 index 00000000..2e3dda5e --- /dev/null +++ b/apps/mobile/src/features/supporters/domain/supporters.repository.requirements.ts @@ -0,0 +1,5 @@ +import { SupportersData } from './entities'; + +export interface SupportersRepositoryRequirements { + getSupporters(): Promise; +} diff --git a/apps/mobile/src/features/supporters/domain/use-cases/get-supporters.use-case.spec.ts b/apps/mobile/src/features/supporters/domain/use-cases/get-supporters.use-case.spec.ts new file mode 100644 index 00000000..d494bf0d --- /dev/null +++ b/apps/mobile/src/features/supporters/domain/use-cases/get-supporters.use-case.spec.ts @@ -0,0 +1,34 @@ +import { mock } from 'jest-mock-extended'; +import { GetSupportersUseCase } from './get-supporters.use-case'; +import { SupportersRepositoryRequirements } from '../supporters.repository.requirements'; +import { SupportersData } from '../entities'; + +describe('The GetSupportersUseCase class', () => { + describe('The execute() method', () => { + describe('When supporters data is requested', () => { + it('should return supporters data from the repository', async () => { + const mockRepo = mock(); + const expectedData: SupportersData = { + hasActiveSupporters: true, + subscribers: [], + supporters: [ + { + coffees: 5, + id: 'sup-1', + name: 'Alice', + supportDate: '2026-08-01T00:00:00.000Z', + }, + ], + totalCoffeesCount: 5, + }; + mockRepo.getSupporters.mockResolvedValue(expectedData); + + const useCase = new GetSupportersUseCase(mockRepo); + const result = await useCase.execute(); + + expect(result).toStrictEqual(expectedData); + expect(mockRepo.getSupporters).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/apps/mobile/src/features/supporters/domain/use-cases/get-supporters.use-case.ts b/apps/mobile/src/features/supporters/domain/use-cases/get-supporters.use-case.ts new file mode 100644 index 00000000..c3b91a50 --- /dev/null +++ b/apps/mobile/src/features/supporters/domain/use-cases/get-supporters.use-case.ts @@ -0,0 +1,10 @@ +import { SupportersData } from '../entities'; +import { SupportersRepositoryRequirements } from '../supporters.repository.requirements'; + +export class GetSupportersUseCase { + public constructor(private readonly repository: SupportersRepositoryRequirements) {} + + public async execute(): Promise { + return this.repository.getSupporters(); + } +} diff --git a/apps/mobile/src/locales/en.json b/apps/mobile/src/locales/en.json index 0e696e8b..c4031e49 100644 --- a/apps/mobile/src/locales/en.json +++ b/apps/mobile/src/locales/en.json @@ -109,7 +109,8 @@ "settings.pushNoToken": "Push notifications cannot be enabled on this device right now. The other settings were saved.", "settings.pushPermissionDenied": "Push permission denied.", "settings.criticalAlertsUnavailable": "This option requires a SentryGuard API update.", - "settings.supportSection": "Support & Community", + "settings.communitySection": "Community & Backers", + "settings.supportSection": "Help & Support", "settings.faq": "Frequently Asked Questions", "settings.faqSubtitle": "Find answers to common questions", "settings.contactSupport": "Live Chat", @@ -137,6 +138,18 @@ "tabs.alerts": "Alerts", "tabs.dashboard": "Dashboard", "tabs.settings": "Settings", + "tabs.supporters": "Supporters", + "supporters.title": "Supporters & Contributors", + "supporters.subtitle": "SentryGuard is an independent open-source project made possible thanks to your support.", + "supporters.bannerTitle": "Thank you to our community", + "supporters.bannerDescription": "SentryGuard is powered and maintained thanks to the generosity and support of its backers.", + "supporters.monthlyContributors": "Monthly Contributors", + "supporters.monthlyBadge": "Monthly Member", + "supporters.member": "Member", + "supporters.monthlyCount": "x{{count}}/mo", + "supporters.donations": "Donors & Contributors", + "supporters.emptyTitle": "Be the first supporter!", + "supporters.emptyDescription": "Help fund infrastructure costs and your name will appear right here.", "vehicle.actions": "Actions", "vehicle.alertSentry": "Sentry alert", "vehicle.alertIntrusion": "Intrusion alert", diff --git a/apps/mobile/src/locales/fr.json b/apps/mobile/src/locales/fr.json index aa30b837..6fbc4ba5 100644 --- a/apps/mobile/src/locales/fr.json +++ b/apps/mobile/src/locales/fr.json @@ -109,7 +109,8 @@ "settings.pushNoToken": "Les notifications push ne peuvent pas être activées sur cet appareil pour le moment. Les autres réglages sont enregistrés.", "settings.pushPermissionDenied": "Permission push refusée.", "settings.criticalAlertsUnavailable": "Cette option nécessite une mise à jour de l’API SentryGuard.", - "settings.supportSection": "Assistance & Communauté", + "settings.communitySection": "Communauté & Soutiens", + "settings.supportSection": "Assistance & Support", "settings.faq": "Foire aux questions", "settings.faqSubtitle": "Réponses aux questions fréquentes", "settings.contactSupport": "Chat en direct", @@ -137,6 +138,18 @@ "tabs.alerts": "Alertes", "tabs.dashboard": "Dashboard", "tabs.settings": "Réglages", + "tabs.supporters": "Contributeurs", + "supporters.title": "Donateurs & Contributeurs", + "supporters.subtitle": "SentryGuard est un projet open-source indépendant rendu possible grâce à vos soutiens.", + "supporters.bannerTitle": "Merci à la communauté", + "supporters.bannerDescription": "SentryGuard est propulsé et maintenu grâce à la générosité et au soutien de ses contributeurs.", + "supporters.monthlyContributors": "Contributeurs mensuels", + "supporters.monthlyBadge": "Membre mensuel", + "supporters.member": "Membre", + "supporters.monthlyCount": "x{{count}}/mois", + "supporters.donations": "Donateurs & Contributeurs", + "supporters.emptyTitle": "Soyez le premier supporter !", + "supporters.emptyDescription": "Aidez à financer les coûts d'infrastructure et votre pseudo apparaîtra ici.", "vehicle.actions": "Actions", "vehicle.alertSentry": "Alerte Sentinelle", "vehicle.alertIntrusion": "Alerte intrusion", diff --git a/apps/mobile/src/screens/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen.tsx index fdadaa2a..39dd8e70 100644 --- a/apps/mobile/src/screens/SettingsScreen.tsx +++ b/apps/mobile/src/screens/SettingsScreen.tsx @@ -58,7 +58,7 @@ export function SettingsScreen({ onLogout }: SettingsScreenProps): JSX.Element { const crispWebsiteId = resolveCrispWebsiteId(); const discordUrl = resolveDiscordUrl(); const supportEmail = resolveSupportEmail(); - const hasSupportLinks = Boolean(faqUrl || crispWebsiteId || discordUrl || supportEmail); + const hasSupportLinks = Boolean(faqUrl || crispWebsiteId || supportEmail); const navigation = useNavigation>(); const statusMessage = @@ -154,6 +154,18 @@ export function SettingsScreen({ onLogout }: SettingsScreenProps): JSX.Element { navigation.navigate('DeleteAccount')} /> + {discordUrl ? ( + + void openDiscordCommunity()} + /> + + ) : null} + {hasSupportLinks ? ( {faqUrl ? ( @@ -174,15 +186,6 @@ export function SettingsScreen({ onLogout }: SettingsScreenProps): JSX.Element { onPress={() => void openCrispSupport(profile?.email, profile?.full_name)} /> ) : null} - {discordUrl ? ( - void openDiscordCommunity()} - /> - ) : null} {supportEmail ? ( getSupportersUseCase.execute(), + queryKey: ['supporters'], + staleTime: 30000, + }); + + const data = supportersQuery.data; + const supporters = data?.supporters ?? []; + const hasSupporters = supporters.length > 0; + + return ( + void supportersQuery.refetch()} + tintColor={colors.secondaryLabel} + /> + } + > + + + {t('supporters.title')} + + + {t('supporters.subtitle')} + + + + + + + + + + + {t('supporters.bannerTitle')} + + + {t('supporters.bannerDescription')} + + + + + + {supporters.length > 0 ? ( + + + + + {t('supporters.donations')} + + + + {supporters.map((supporter) => ( + + ))} + + + ) : null} + + {!hasSupporters && !supportersQuery.isLoading ? ( + + + + {t('supporters.emptyTitle')} + + + {t('supporters.emptyDescription')} + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + cardsList: { + gap: spacing.sm, + }, + content: { + gap: spacing.xl, + paddingBottom: spacing.xxl * 2, + paddingHorizontal: screenPadding, + paddingTop: spacing.sm, + }, + bannerCard: { + gap: spacing.md, + }, + bannerHeader: { + alignItems: 'center', + flexDirection: 'row', + gap: spacing.md, + }, + bannerText: { + flex: 1, + gap: spacing.xs, + }, + emptyCard: { + alignItems: 'center', + gap: spacing.sm, + paddingVertical: spacing.xl, + }, + emptyDescription: { + textAlign: 'center', + }, + emptyText: { + textAlign: 'center', + }, + emptyTitle: { + textAlign: 'center', + }, + header: { + gap: spacing.xs, + }, + iconCircle: { + alignItems: 'center', + borderRadius: 999, + height: 40, + justifyContent: 'center', + width: 40, + }, + section: { + gap: spacing.md, + }, + sectionHeader: { + alignItems: 'center', + flexDirection: 'row', + gap: spacing.sm, + }, + title: { + paddingTop: spacing.sm, + }, +}); diff --git a/apps/mobile/src/screens/supporters/components/SupporterCard.tsx b/apps/mobile/src/screens/supporters/components/SupporterCard.tsx new file mode 100644 index 00000000..1c672b09 --- /dev/null +++ b/apps/mobile/src/screens/supporters/components/SupporterCard.tsx @@ -0,0 +1,215 @@ +import type { JSX } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { radius, spacing } from '../../../core/design/metrics'; +import { TextVariant } from '../../../core/design/typography'; +import { useThemeColors } from '../../../core/theme'; +import { AppText, Icon, Surface } from '../../../core/ui'; +import { Supporter } from '../../../features/supporters/domain/entities'; + +interface SupporterCardProps { + language: string; + supporter: Supporter; +} + +export function SupporterCard({ language, supporter }: SupporterCardProps): JSX.Element { + const { t } = useTranslation(); + const colors = useThemeColors(); + const isVip = supporter.coffees >= 10; + const initial = (supporter.name || 'A').charAt(0).toUpperCase(); + + return ( + + + + {isVip ? ( + + ) : ( + + {initial} + + )} + + + + + {supporter.name} + + {isVip ? ( + + + VIP + + + ) : null} + + + {formatDate(supporter.supportDate, language)} + + + + {supporter.isSubscriber ? ( + + + + {supporter.monthlyCoffees + ? t('supporters.monthlyCount', { count: supporter.monthlyCoffees }) + : t('supporters.member')} + + + ) : null} + + + + x{supporter.coffees} + + + + + {supporter.message?.trim() ? ( + + + « {supporter.message} » + + + ) : null} + + ); +} + +function formatDate(dateStr: string, language: string): string { + try { + const date = new Date(dateStr); + return new Intl.DateTimeFormat(language || 'fr-FR', { + dateStyle: 'medium', + }).format(date); + } catch { + return dateStr; + } +} + +const styles = StyleSheet.create({ + avatar: { + alignItems: 'center', + borderRadius: radius.capsule, + borderWidth: 1, + height: 40, + justifyContent: 'center', + width: 40, + }, + badge: { + alignItems: 'center', + borderRadius: radius.control, + borderWidth: 1, + flexDirection: 'row', + flexShrink: 0, + gap: 4, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + }, + badgeContainer: { + alignItems: 'flex-end', + flexDirection: 'column', + flexShrink: 0, + gap: 4, + }, + card: { + gap: spacing.sm, + }, + header: { + alignItems: 'center', + flexDirection: 'row', + gap: spacing.md, + }, + messageBox: { + borderRadius: radius.control, + marginTop: spacing.xs, + padding: spacing.sm, + }, + nameBlock: { + flex: 1, + minWidth: 0, + }, + nameRow: { + alignItems: 'center', + flexDirection: 'row', + gap: spacing.xs, + }, + nameText: { + flexShrink: 1, + }, + vipPill: { + borderRadius: 4, + borderWidth: 1, + paddingHorizontal: spacing.xs, + paddingVertical: 2, + }, + vipPillText: { + fontWeight: '700', + }, +}); diff --git a/apps/webapp/src/app/[locale]/faq/page.tsx b/apps/webapp/src/app/[locale]/faq/page.tsx index d2681105..0a506f64 100644 --- a/apps/webapp/src/app/[locale]/faq/page.tsx +++ b/apps/webapp/src/app/[locale]/faq/page.tsx @@ -103,6 +103,12 @@ export default async function FAQPage({ params }: FaqPageProps) { href: `/${locale}`, primary: false, }, + { + label: 'Supporters', + href: `/${locale}/supporters`, + icon: ❤️, + primary: false, + }, ]} >