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.
This commit is contained in:
Dorian
2026-02-14 15:35:59 +00:00
parent bb4f13fc65
commit 66db9376ed
10 changed files with 208 additions and 16 deletions

View File

@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddZapStats1762000000000 implements MigrationInterface {
name = 'AddZapStats1762000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`DROP TABLE "zap_stats"`);
}
}

View File

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

View File

@@ -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[];
}

View File

@@ -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);
}
}

View File

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

View File

@@ -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<ZapStats>,
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<void> {
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<Record<string, ProjectZapStats>> {
if (projectIds.length === 0) return {};
const rows = await this.zapStatsRepository.find({
where: { projectId: In(projectIds) },
});
const result: Record<string, ProjectZapStats> = {};
for (const row of rows) {
result[row.projectId] = {
zapCount: row.zapCount,
zapAmountSats: row.zapAmountSats,
recentZapperPubkeys: Array.isArray(row.recentZapperPubkeys) ? row.recentZapperPubkeys : [],
};
}
return result;
}
}