From 66db9376ed29777b2d6fe1f0a692abe477e49218 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sat, 14 Feb 2026 15:35:59 +0000 Subject: [PATCH] feat: enhance zap functionality with stats tracking and pubkey support - Added a new endpoint in ZapsController to retrieve zap statistics by project IDs, including total counts, amounts, and recent zapper pubkeys. - Updated ZapsService to record zap statistics, including optional zapper pubkey for tracking who zapped. - Enhanced CreateZapInvoiceDto to include an optional zapperPubkey field. - Modified frontend components to display zap stats and integrate with the new backend functionality, improving user engagement and transparency. These changes improve the overall zap experience by providing detailed insights into zap activities and enhancing the tracking of contributors. --- .../migrations/1762000000000-add-zap-stats.ts | 21 +++++ .../src/zaps/dto/create-zap-invoice.dto.ts | 7 +- backend/src/zaps/entities/zap-stats.entity.ts | 16 ++++ backend/src/zaps/zaps.controller.ts | 13 +++- backend/src/zaps/zaps.module.ts | 8 +- backend/src/zaps/zaps.service.ts | 76 +++++++++++++++++-- src/components/ContentRow.vue | 43 ++++++++++- src/components/ZapModal.vue | 4 + src/composables/useContentDiscovery.ts | 23 +++++- src/services/indeehub-api.service.ts | 13 ++++ 10 files changed, 208 insertions(+), 16 deletions(-) create mode 100644 backend/src/database/migrations/1762000000000-add-zap-stats.ts create mode 100644 backend/src/zaps/entities/zap-stats.entity.ts diff --git a/backend/src/database/migrations/1762000000000-add-zap-stats.ts b/backend/src/database/migrations/1762000000000-add-zap-stats.ts new file mode 100644 index 0000000..741b5ee --- /dev/null +++ b/backend/src/database/migrations/1762000000000-add-zap-stats.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddZapStats1762000000000 implements MigrationInterface { + name = 'AddZapStats1762000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "zap_stats" ( + "project_id" character varying NOT NULL, + "zap_count" integer NOT NULL DEFAULT 0, + "zap_amount_sats" integer NOT NULL DEFAULT 0, + "recent_zapper_pubkeys" jsonb NOT NULL DEFAULT '[]', + CONSTRAINT "PK_zap_stats" PRIMARY KEY ("project_id") + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "zap_stats"`); + } +} diff --git a/backend/src/zaps/dto/create-zap-invoice.dto.ts b/backend/src/zaps/dto/create-zap-invoice.dto.ts index 15f7682..d4a1e1a 100644 --- a/backend/src/zaps/dto/create-zap-invoice.dto.ts +++ b/backend/src/zaps/dto/create-zap-invoice.dto.ts @@ -1,4 +1,4 @@ -import { IsNumber, IsString, Min } from 'class-validator'; +import { IsNumber, IsOptional, IsString, Min } from 'class-validator'; export class CreateZapInvoiceDto { @IsString() @@ -10,4 +10,9 @@ export class CreateZapInvoiceDto { @IsNumber() @Min(1, { message: 'Amount must be at least 1 satoshi' }) amountSats: number; + + /** Nostr pubkey of the zapper (hex) — used to show "who zapped" on film cards. */ + @IsOptional() + @IsString() + zapperPubkey?: string; } diff --git a/backend/src/zaps/entities/zap-stats.entity.ts b/backend/src/zaps/entities/zap-stats.entity.ts new file mode 100644 index 0000000..d92d267 --- /dev/null +++ b/backend/src/zaps/entities/zap-stats.entity.ts @@ -0,0 +1,16 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity('zap_stats') +export class ZapStats { + @PrimaryColumn() + projectId: string; + + @Column('int', { default: 0 }) + zapCount: number; + + @Column('int', { default: 0 }) + zapAmountSats: number; + + @Column({ type: 'jsonb', default: [] }) + recentZapperPubkeys: string[]; +} diff --git a/backend/src/zaps/zaps.controller.ts b/backend/src/zaps/zaps.controller.ts index b38194c..69197d9 100644 --- a/backend/src/zaps/zaps.controller.ts +++ b/backend/src/zaps/zaps.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { ZapsService } from './zaps.service'; import { CreateZapInvoiceDto } from './dto/create-zap-invoice.dto'; @@ -12,6 +12,7 @@ export class ZapsController { dto.projectId, dto.filmmakerId, dto.amountSats, + dto.zapperPubkey, ); } @@ -19,4 +20,14 @@ export class ZapsController { async getQuote(@Param('invoiceId') invoiceId: string) { return this.zapsService.getQuote(invoiceId); } + + /** + * Get zap stats for film cards: total count, amount, and recent zapper pubkeys. + * Query: projectIds=id1,id2,id3 + */ + @Get('stats') + async getStats(@Query('projectIds') projectIds: string) { + const ids = projectIds ? projectIds.split(',').filter(Boolean) : []; + return this.zapsService.getStats(ids); + } } diff --git a/backend/src/zaps/zaps.module.ts b/backend/src/zaps/zaps.module.ts index 53001e3..22d0b23 100644 --- a/backend/src/zaps/zaps.module.ts +++ b/backend/src/zaps/zaps.module.ts @@ -1,11 +1,17 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { ZapsController } from './zaps.controller'; import { ZapsService } from './zaps.service'; +import { ZapStats } from './entities/zap-stats.entity'; import { PaymentModule } from 'src/payment/payment.module'; import { FilmmakersModule } from 'src/filmmakers/filmmakers.module'; @Module({ - imports: [PaymentModule, FilmmakersModule], + imports: [ + TypeOrmModule.forFeature([ZapStats]), + PaymentModule, + FilmmakersModule, + ], controllers: [ZapsController], providers: [ZapsService], exports: [ZapsService], diff --git a/backend/src/zaps/zaps.service.ts b/backend/src/zaps/zaps.service.ts index 4518c27..a818350 100644 --- a/backend/src/zaps/zaps.service.ts +++ b/backend/src/zaps/zaps.service.ts @@ -1,16 +1,28 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; import { BTCPayService } from 'src/payment/providers/services/btcpay.service'; import { FilmmakersService } from 'src/filmmakers/filmmakers.service'; import Invoice from 'src/payment/providers/dto/strike/invoice'; import InvoiceQuote from 'src/payment/providers/dto/strike/invoice-quote'; +import { ZapStats } from './entities/zap-stats.entity'; const ZAP_ORDER_PREFIX = 'zap:'; +const MAX_RECENT_ZAPPERS = 5; + +export interface ProjectZapStats { + zapCount: number; + zapAmountSats: number; + recentZapperPubkeys: string[]; +} @Injectable() export class ZapsService { private readonly logger = new Logger(ZapsService.name); constructor( + @InjectRepository(ZapStats) + private readonly zapStatsRepository: Repository, private readonly btcpayService: BTCPayService, private readonly filmmakersService: FilmmakersService, ) {} @@ -18,12 +30,21 @@ export class ZapsService { /** * Create a BTCPay invoice for a zap. User pays this invoice; on settlement * we pay out to the creator's Lightning address. Correlation is stored so - * the webhook can route the payout. + * the webhook can route the payout. Optional zapperPubkey is stored so we + * can show "who zapped" on film cards. */ - async createInvoice(projectId: string, filmmakerId: string, amountSats: number) { + async createInvoice( + projectId: string, + filmmakerId: string, + amountSats: number, + zapperPubkey?: string, + ) { await this.filmmakersService.getFilmmakerLightningAddress(filmmakerId); - const correlationId = `${ZAP_ORDER_PREFIX}${projectId}:${filmmakerId}`; + let correlationId = `${ZAP_ORDER_PREFIX}${projectId}:${filmmakerId}`; + if (zapperPubkey) { + correlationId += `:${zapperPubkey}`; + } const invoice = await this.btcpayService.issueInvoice( amountSats, `Zap — project ${projectId}`, @@ -62,7 +83,8 @@ export class ZapsService { this.logger.warn(`Invalid zap correlation: ${invoice.correlationId}`); return; } - const [_projectId, filmmakerId] = parts; + const [projectId, filmmakerId] = parts; + const zapperPubkey = parts.length >= 3 ? parts[2] : undefined; const amountBtc = parseFloat(invoice.amount?.amount ?? '0'); const sats = Math.floor(amountBtc * 100_000_000); @@ -84,8 +106,52 @@ export class ZapsService { await this.btcpayService.payToLightningAddress( address, sats, - `IndeeHub zap — project ${parts[0]}`, + `IndeeHub zap — project ${projectId}`, ); + await this.recordZapStats(projectId, sats, zapperPubkey); this.logger.log(`Zap payout completed: ${invoiceId}`); } + + private async recordZapStats(projectId: string, sats: number, zapperPubkey?: string): Promise { + let row = await this.zapStatsRepository.findOneBy({ projectId }); + if (!row) { + row = this.zapStatsRepository.create({ + projectId, + zapCount: 0, + zapAmountSats: 0, + recentZapperPubkeys: [], + }); + } + row.zapCount += 1; + row.zapAmountSats += sats; + if ( + zapperPubkey && + Array.isArray(row.recentZapperPubkeys) && + row.recentZapperPubkeys.length < MAX_RECENT_ZAPPERS && + !row.recentZapperPubkeys.includes(zapperPubkey) + ) { + row.recentZapperPubkeys = [...row.recentZapperPubkeys, zapperPubkey]; + } + await this.zapStatsRepository.save(row); + } + + /** + * Get zap stats for the given project IDs (for film cards: total + who zapped). + * Persisted in DB so stats survive restarts. + */ + async getStats(projectIds: string[]): Promise> { + if (projectIds.length === 0) return {}; + const rows = await this.zapStatsRepository.find({ + where: { projectId: In(projectIds) }, + }); + const result: Record = {}; + for (const row of rows) { + result[row.projectId] = { + zapCount: row.zapCount, + zapAmountSats: row.zapAmountSats, + recentZapperPubkeys: Array.isArray(row.recentZapperPubkeys) ? row.recentZapperPubkeys : [], + }; + } + return result; + } } diff --git a/src/components/ContentRow.vue b/src/components/ContentRow.vue index 2db7146..168316f 100644 --- a/src/components/ContentRow.vue +++ b/src/components/ContentRow.vue @@ -89,22 +89,46 @@