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:
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
16
backend/src/zaps/entities/zap-stats.entity.ts
Normal file
16
backend/src/zaps/entities/zap-stats.entity.ts
Normal 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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,22 +89,46 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import type { Content } from '../types/content'
|
||||
import { useContentDiscovery } from '../composables/useContentDiscovery'
|
||||
import { indeehubApiService } from '../services/indeehub-api.service'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
contents: Content[]
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const props = defineProps<Props>()
|
||||
defineEmits<{
|
||||
'content-click': [content: Content]
|
||||
}>()
|
||||
|
||||
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 {
|
||||
return getStats(contentId).plusCount ?? 0
|
||||
}
|
||||
@@ -114,11 +138,22 @@ function getCommentCount(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[] {
|
||||
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 {
|
||||
|
||||
@@ -204,6 +204,7 @@ import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import QRCode from 'qrcode'
|
||||
import type { Content } from '../types/content'
|
||||
import { indeehubApiService } from '../services/indeehub-api.service'
|
||||
import { useAuth } from '../composables/useAuth'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
@@ -244,6 +245,8 @@ const creatorName = ref<string | null>(null)
|
||||
const noCreator = ref(false)
|
||||
const successQuote = ref('')
|
||||
|
||||
const { nostrPubkey } = useAuth()
|
||||
|
||||
// Creator id for backend zap invoice (we pay user → BTCPay → we pay creator)
|
||||
const ownerFilmmakerId = ref<string | null>(null)
|
||||
const zapInvoiceId = ref<string | null>(null)
|
||||
@@ -436,6 +439,7 @@ async function handleZap() {
|
||||
projectId: props.content.id,
|
||||
filmmakerId: ownerFilmmakerId.value,
|
||||
amountSats,
|
||||
...(nostrPubkey.value ? { zapperPubkey: nostrPubkey.value } : {}),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -78,6 +78,12 @@ function getTagValue(event: NostrEvent, tagName: string): string | undefined {
|
||||
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
|
||||
*/
|
||||
@@ -98,6 +104,9 @@ function rebuildStats() {
|
||||
recentEvents: [],
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -279,10 +288,11 @@ export function useContentDiscovery() {
|
||||
|
||||
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 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
|
||||
@@ -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 {
|
||||
const externalId = getExternalContentId(contentId)
|
||||
return contentStatsMap.value.get(externalId) || EMPTY_STATS
|
||||
return (
|
||||
contentStatsMap.value.get(externalId) ||
|
||||
contentStatsMap.value.get(contentId) ||
|
||||
EMPTY_STATS
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -204,6 +204,19 @@ class IndeehubApiService {
|
||||
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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user