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 { export class CreateZapInvoiceDto {
@IsString() @IsString()
@@ -10,4 +10,9 @@ export class CreateZapInvoiceDto {
@IsNumber() @IsNumber()
@Min(1, { message: 'Amount must be at least 1 satoshi' }) @Min(1, { message: 'Amount must be at least 1 satoshi' })
amountSats: number; 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 { ZapsService } from './zaps.service';
import { CreateZapInvoiceDto } from './dto/create-zap-invoice.dto'; import { CreateZapInvoiceDto } from './dto/create-zap-invoice.dto';
@@ -12,6 +12,7 @@ export class ZapsController {
dto.projectId, dto.projectId,
dto.filmmakerId, dto.filmmakerId,
dto.amountSats, dto.amountSats,
dto.zapperPubkey,
); );
} }
@@ -19,4 +20,14 @@ export class ZapsController {
async getQuote(@Param('invoiceId') invoiceId: string) { async getQuote(@Param('invoiceId') invoiceId: string) {
return this.zapsService.getQuote(invoiceId); 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 { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ZapsController } from './zaps.controller'; import { ZapsController } from './zaps.controller';
import { ZapsService } from './zaps.service'; import { ZapsService } from './zaps.service';
import { ZapStats } from './entities/zap-stats.entity';
import { PaymentModule } from 'src/payment/payment.module'; import { PaymentModule } from 'src/payment/payment.module';
import { FilmmakersModule } from 'src/filmmakers/filmmakers.module'; import { FilmmakersModule } from 'src/filmmakers/filmmakers.module';
@Module({ @Module({
imports: [PaymentModule, FilmmakersModule], imports: [
TypeOrmModule.forFeature([ZapStats]),
PaymentModule,
FilmmakersModule,
],
controllers: [ZapsController], controllers: [ZapsController],
providers: [ZapsService], providers: [ZapsService],
exports: [ZapsService], exports: [ZapsService],

View File

@@ -1,16 +1,28 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; 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 { BTCPayService } from 'src/payment/providers/services/btcpay.service';
import { FilmmakersService } from 'src/filmmakers/filmmakers.service'; import { FilmmakersService } from 'src/filmmakers/filmmakers.service';
import Invoice from 'src/payment/providers/dto/strike/invoice'; import Invoice from 'src/payment/providers/dto/strike/invoice';
import InvoiceQuote from 'src/payment/providers/dto/strike/invoice-quote'; import InvoiceQuote from 'src/payment/providers/dto/strike/invoice-quote';
import { ZapStats } from './entities/zap-stats.entity';
const ZAP_ORDER_PREFIX = 'zap:'; const ZAP_ORDER_PREFIX = 'zap:';
const MAX_RECENT_ZAPPERS = 5;
export interface ProjectZapStats {
zapCount: number;
zapAmountSats: number;
recentZapperPubkeys: string[];
}
@Injectable() @Injectable()
export class ZapsService { export class ZapsService {
private readonly logger = new Logger(ZapsService.name); private readonly logger = new Logger(ZapsService.name);
constructor( constructor(
@InjectRepository(ZapStats)
private readonly zapStatsRepository: Repository<ZapStats>,
private readonly btcpayService: BTCPayService, private readonly btcpayService: BTCPayService,
private readonly filmmakersService: FilmmakersService, private readonly filmmakersService: FilmmakersService,
) {} ) {}
@@ -18,12 +30,21 @@ export class ZapsService {
/** /**
* Create a BTCPay invoice for a zap. User pays this invoice; on settlement * 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 * 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); 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( const invoice = await this.btcpayService.issueInvoice(
amountSats, amountSats,
`Zap — project ${projectId}`, `Zap — project ${projectId}`,
@@ -62,7 +83,8 @@ export class ZapsService {
this.logger.warn(`Invalid zap correlation: ${invoice.correlationId}`); this.logger.warn(`Invalid zap correlation: ${invoice.correlationId}`);
return; 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 amountBtc = parseFloat(invoice.amount?.amount ?? '0');
const sats = Math.floor(amountBtc * 100_000_000); const sats = Math.floor(amountBtc * 100_000_000);
@@ -84,8 +106,52 @@ export class ZapsService {
await this.btcpayService.payToLightningAddress( await this.btcpayService.payToLightningAddress(
address, address,
sats, sats,
`IndeeHub zap — project ${parts[0]}`, `IndeeHub zap — project ${projectId}`,
); );
await this.recordZapStats(projectId, sats, zapperPubkey);
this.logger.log(`Zap payout completed: ${invoiceId}`); 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;
}
} }

View File

@@ -89,22 +89,46 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { ref, watch, onMounted, onUnmounted } from 'vue'
import type { Content } from '../types/content' import type { Content } from '../types/content'
import { useContentDiscovery } from '../composables/useContentDiscovery' import { useContentDiscovery } from '../composables/useContentDiscovery'
import { indeehubApiService } from '../services/indeehub-api.service'
interface Props { interface Props {
title: string title: string
contents: Content[] contents: Content[]
} }
defineProps<Props>() const props = defineProps<Props>()
defineEmits<{ defineEmits<{
'content-click': [content: Content] 'content-click': [content: Content]
}>() }>()
const { getStats } = useContentDiscovery() const { getStats } = useContentDiscovery()
/** Backend zap stats (BTCPay zaps) so film cards show total + who zapped. */
const backendZapStats = ref<Record<string, { zapCount: number; zapAmountSats: number; recentZapperPubkeys: string[] }>>({})
watch(
() => props.contents,
(contents) => {
const ids = contents?.map((c) => c.id).filter(Boolean) ?? []
if (ids.length === 0) {
backendZapStats.value = {}
return
}
indeehubApiService
.getZapStats(ids)
.then((data) => {
backendZapStats.value = data
})
.catch(() => {
backendZapStats.value = {}
})
},
{ immediate: true },
)
function getReactionCount(contentId: string): number { function getReactionCount(contentId: string): number {
return getStats(contentId).plusCount ?? 0 return getStats(contentId).plusCount ?? 0
} }
@@ -114,11 +138,22 @@ function getCommentCount(contentId: string): number {
} }
function getZapCount(contentId: string): number { function getZapCount(contentId: string): number {
return getStats(contentId).zapCount ?? 0 const discovery = getStats(contentId).zapCount ?? 0
const backend = backendZapStats.value[contentId]?.zapCount ?? 0
return discovery + backend
} }
function getZapperPubkeys(contentId: string): string[] { function getZapperPubkeys(contentId: string): string[] {
return getStats(contentId).recentZapperPubkeys ?? [] const discovery = getStats(contentId).recentZapperPubkeys ?? []
const backend = backendZapStats.value[contentId]?.recentZapperPubkeys ?? []
const seen = new Set<string>()
const merged: string[] = []
for (const pk of [...discovery, ...backend]) {
if (seen.has(pk) || merged.length >= 5) continue
seen.add(pk)
merged.push(pk)
}
return merged
} }
function zapperAvatarUrl(pubkey: string): string { function zapperAvatarUrl(pubkey: string): string {

View File

@@ -204,6 +204,7 @@ import { ref, computed, watch, onUnmounted } from 'vue'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import type { Content } from '../types/content' import type { Content } from '../types/content'
import { indeehubApiService } from '../services/indeehub-api.service' import { indeehubApiService } from '../services/indeehub-api.service'
import { useAuth } from '../composables/useAuth'
interface Props { interface Props {
isOpen: boolean isOpen: boolean
@@ -244,6 +245,8 @@ const creatorName = ref<string | null>(null)
const noCreator = ref(false) const noCreator = ref(false)
const successQuote = ref('') const successQuote = ref('')
const { nostrPubkey } = useAuth()
// Creator id for backend zap invoice (we pay user → BTCPay → we pay creator) // Creator id for backend zap invoice (we pay user → BTCPay → we pay creator)
const ownerFilmmakerId = ref<string | null>(null) const ownerFilmmakerId = ref<string | null>(null)
const zapInvoiceId = ref<string | null>(null) const zapInvoiceId = ref<string | null>(null)
@@ -436,6 +439,7 @@ async function handleZap() {
projectId: props.content.id, projectId: props.content.id,
filmmakerId: ownerFilmmakerId.value, filmmakerId: ownerFilmmakerId.value,
amountSats, amountSats,
...(nostrPubkey.value ? { zapperPubkey: nostrPubkey.value } : {}),
}, },
) )

View File

@@ -78,6 +78,12 @@ function getTagValue(event: NostrEvent, tagName: string): string | undefined {
return event.tags.find((t) => t[0] === tagName)?.[1] return event.tags.find((t) => t[0] === tagName)?.[1]
} }
/** If externalId is a URL like .../content/ID, return ID so we can index by both. */
function bareContentIdFromExternal(externalId: string): string | null {
const match = externalId.match(/\/content\/([^/]+)$/)
return match ? match[1] : null
}
/** /**
* Rebuild the stats map from current EventStore data * Rebuild the stats map from current EventStore data
*/ */
@@ -98,6 +104,9 @@ function rebuildStats() {
recentEvents: [], recentEvents: [],
} }
map.set(id, stats) map.set(id, stats)
// Index by bare content id too (e.g. project id) so getStats(content.id) finds it
const bare = bareContentIdFromExternal(id)
if (bare && bare !== id) map.set(bare, stats)
} }
return stats return stats
} }
@@ -279,10 +288,11 @@ export function useContentDiscovery() {
const statsMap = contentStatsMap.value const statsMap = contentStatsMap.value
// Build entries array: [Content, stats] for each content item // Build entries array: [Content, stats] for each content item (same dual-key as getStats)
const withStats: [Content, ContentStats][] = contents.map((c) => { const withStats: [Content, ContentStats][] = contents.map((c) => {
const externalId = getExternalContentId(c.id) const externalId = getExternalContentId(c.id)
return [c, statsMap.get(externalId) || EMPTY_STATS] const stats = statsMap.get(externalId) || statsMap.get(c.id) || EMPTY_STATS
return [c, stats]
}) })
// Sort entries // Sort entries
@@ -316,11 +326,16 @@ export function useContentDiscovery() {
} }
/** /**
* Get stats for a specific content item * Get stats for a specific content item (Nostr discovery).
* Lookup by full external URL and by bare content id so relay data matches either format.
*/ */
function getStats(contentId: string): ContentStats { function getStats(contentId: string): ContentStats {
const externalId = getExternalContentId(contentId) const externalId = getExternalContentId(contentId)
return contentStatsMap.value.get(externalId) || EMPTY_STATS return (
contentStatsMap.value.get(externalId) ||
contentStatsMap.value.get(contentId) ||
EMPTY_STATS
)
} }
return { return {

View File

@@ -204,6 +204,19 @@ class IndeehubApiService {
return response.data return response.data
} }
/**
* Get zap stats for film cards (count, amount, recent zapper pubkeys) by project id.
*/
async getZapStats(projectIds: string[]): Promise<Record<string, { zapCount: number; zapAmountSats: number; recentZapperPubkeys: string[] }>> {
if (projectIds.length === 0) return {}
const ids = projectIds.join(',')
const response = await this.client.get<Record<string, { zapCount: number; zapAmountSats: number; recentZapperPubkeys: string[] }>>(
'/zaps/stats',
{ params: { projectIds: ids } },
)
return response.data ?? {}
}
/** /**
* Get the CDN URL for a storage path * Get the CDN URL for a storage path
*/ */