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 @@