diff --git a/src/app.ts b/src/app.ts index b3a2687..46eb6f5 100644 --- a/src/app.ts +++ b/src/app.ts @@ -49,7 +49,7 @@ import { createSwapRouter } from "./routes/swap.routes"; import { createRoyaltiesRouter } from "./routes/royalties.routes"; import { createSubscriptionsRouter } from "./routes/subscriptions.routes"; import { createOnboardingRouter } from "./routes/onboarding.routes"; -import { createIssuerReputationRouter } from "./routes/issuer-reputation.routes"; +import { createCommunityProgramsRouter } from "./routes/community-programs.routes"; import type { AuthService } from "./services/auth.service"; import type { NotificationService } from "./services/notification.service"; @@ -413,6 +413,7 @@ export function createApp({ app.use("/api/v1/auth", createAuthRouter(authService, appLogger)); app.use("/auth", createAuthRouter(authService, appLogger)); + app.use("/api/v1", createCommunityProgramsRouter(dataSource, authService)); if (kycService) { app.use("/api/v1/kyc", createKycRouter(kycService, authService)); diff --git a/src/migrations/1754000000000-CreateCommunityProgramTables.ts b/src/migrations/1754000000000-CreateCommunityProgramTables.ts new file mode 100644 index 0000000..89a99bb --- /dev/null +++ b/src/migrations/1754000000000-CreateCommunityProgramTables.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from "typeorm"; + +export class CreateCommunityProgramTables1754000000000 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable(new Table({ + name: "invoice_fraud_reports", + columns: [ + { name: "id", type: "uuid", isPrimary: true, generationStrategy: "uuid", isGenerated: true }, + { name: "invoice_id", type: "uuid" }, { name: "reporter_wallet", type: "varchar", length: "56" }, + { name: "reason", type: "varchar", length: "64" }, { name: "detail", type: "text" }, + { name: "status", type: "varchar", length: "32", default: "'open'" }, + { name: "review_note", type: "text", isNullable: true }, + { name: "created_at", type: "timestamp with time zone", default: "now()" }, + { name: "updated_at", type: "timestamp with time zone", default: "now()" }, + ], + }), true); + await queryRunner.createIndex("invoice_fraud_reports", new TableIndex({ name: "uq_invoice_fraud_report_wallet", columnNames: ["invoice_id", "reporter_wallet"], isUnique: true })); + await queryRunner.createTable(new Table({ + name: "referrals", + columns: [ + { name: "id", type: "uuid", isPrimary: true, generationStrategy: "uuid", isGenerated: true }, + { name: "referrer_wallet", type: "varchar", length: "56" }, { name: "referee_wallet", type: "varchar", length: "56" }, + { name: "earned_amount", type: "decimal", precision: 18, scale: 4, default: 0 }, + { name: "claimed_amount", type: "decimal", precision: 18, scale: 4, default: 0 }, + { name: "created_at", type: "timestamp with time zone", default: "now()" }, + ], + }), true); + await queryRunner.createIndex("referrals", new TableIndex({ name: "uq_referral_referee", columnNames: ["referee_wallet"], isUnique: true })); + await queryRunner.createTable(new Table({ + name: "waitlist_entries", + columns: [ + { name: "id", type: "uuid", isPrimary: true, generationStrategy: "uuid", isGenerated: true }, + { name: "wallet", type: "varchar", length: "56" }, { name: "email", type: "varchar", length: "255" }, + { name: "referral_code", type: "varchar", length: "64" }, { name: "referral_count", type: "integer", default: 0 }, + { name: "status", type: "varchar", length: "24", default: "'pending'" }, + { name: "created_at", type: "timestamp with time zone", default: "now()" }, + { name: "updated_at", type: "timestamp with time zone", default: "now()" }, + ], + }), true); + await queryRunner.createIndex("waitlist_entries", new TableIndex({ name: "uq_waitlist_wallet", columnNames: ["wallet"], isUnique: true })); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("waitlist_entries", true); + await queryRunner.dropTable("referrals", true); + await queryRunner.dropTable("invoice_fraud_reports", true); + } +} diff --git a/src/models/InvoiceFraudReport.model.ts b/src/models/InvoiceFraudReport.model.ts new file mode 100644 index 0000000..daa3493 --- /dev/null +++ b/src/models/InvoiceFraudReport.model.ts @@ -0,0 +1,40 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm"; + +export enum FraudReportStatus { + OPEN = "open", + INVESTIGATING = "investigating", + RESOLVED = "resolved", + DISMISSED = "dismissed", +} + +@Entity("invoice_fraud_reports") +@Index("uq_invoice_fraud_report_wallet", ["invoiceId", "reporterWallet"], { unique: true }) +@Index("idx_invoice_fraud_report_status", ["status", "createdAt"]) +export class InvoiceFraudReport { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ name: "invoice_id", type: "uuid" }) + invoiceId!: string; + + @Column({ name: "reporter_wallet", type: "varchar", length: 56 }) + reporterWallet!: string; + + @Column({ type: "varchar", length: 64 }) + reason!: string; + + @Column({ type: "text" }) + detail!: string; + + @Column({ type: "varchar", length: 32, default: FraudReportStatus.OPEN }) + status!: FraudReportStatus; + + @Column({ name: "review_note", type: "text", nullable: true }) + reviewNote!: string | null; + + @CreateDateColumn({ name: "created_at" }) + createdAt!: Date; + + @UpdateDateColumn({ name: "updated_at" }) + updatedAt!: Date; +} diff --git a/src/models/Referral.model.ts b/src/models/Referral.model.ts new file mode 100644 index 0000000..7033aa2 --- /dev/null +++ b/src/models/Referral.model.ts @@ -0,0 +1,24 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from "typeorm"; + +@Entity("referrals") +@Index("uq_referral_referee", ["refereeWallet"], { unique: true }) +@Index("idx_referral_referrer", ["referrerWallet", "createdAt"]) +export class Referral { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ name: "referrer_wallet", type: "varchar", length: 56 }) + referrerWallet!: string; + + @Column({ name: "referee_wallet", type: "varchar", length: 56 }) + refereeWallet!: string; + + @Column({ name: "earned_amount", type: "decimal", precision: 18, scale: 4, default: 0 }) + earnedAmount!: string; + + @Column({ name: "claimed_amount", type: "decimal", precision: 18, scale: 4, default: 0 }) + claimedAmount!: string; + + @CreateDateColumn({ name: "created_at" }) + createdAt!: Date; +} diff --git a/src/models/WaitlistEntry.model.ts b/src/models/WaitlistEntry.model.ts new file mode 100644 index 0000000..5eeb9e9 --- /dev/null +++ b/src/models/WaitlistEntry.model.ts @@ -0,0 +1,30 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm"; + +@Entity("waitlist_entries") +@Index("uq_waitlist_wallet", ["wallet"], { unique: true }) +@Index("idx_waitlist_status_created", ["status", "createdAt"]) +export class WaitlistEntry { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ type: "varchar", length: 56 }) + wallet!: string; + + @Column({ type: "varchar", length: 255 }) + email!: string; + + @Column({ name: "referral_code", type: "varchar", length: 64, unique: true }) + referralCode!: string; + + @Column({ name: "referral_count", type: "integer", default: 0 }) + referralCount!: number; + + @Column({ type: "varchar", length: 24, default: "pending" }) + status!: "pending" | "approved"; + + @CreateDateColumn({ name: "created_at" }) + createdAt!: Date; + + @UpdateDateColumn({ name: "updated_at" }) + updatedAt!: Date; +} diff --git a/src/routes/community-programs.routes.ts b/src/routes/community-programs.routes.ts new file mode 100644 index 0000000..d6c6118 --- /dev/null +++ b/src/routes/community-programs.routes.ts @@ -0,0 +1,70 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { DataSource } from "typeorm"; +import { createAuthMiddleware, requireAdmin } from "../middleware/auth.middleware"; +import type { AuthService } from "../services/auth.service"; +import type { AuthenticatedRequest } from "../types/auth"; +import { CommunityProgramsService } from "../services/community-programs.service"; +import { FraudReportStatus } from "../models/InvoiceFraudReport.model"; +import { WaitlistEntry } from "../models/WaitlistEntry.model"; + +export function createCommunityProgramsRouter(dataSource: DataSource, authService: AuthService): Router { + const router = Router(); + const service = new CommunityProgramsService(dataSource); + const authenticate = createAuthMiddleware(authService); + const forward = (next: NextFunction, error: unknown) => next(error); + + router.post("/referrals/register", authenticate, async (req: AuthenticatedRequest, res: Response, next) => { + try { res.status(201).json({ success: true, data: await service.registerReferral(String(req.body?.referrerWallet ?? ""), String(req.user?.stellarAddress ?? "")) }); } + catch (error) { forward(next, error); } + }); + router.get("/referrals/stats", authenticate, async (req: Request, res: Response, next) => { + try { res.json({ success: true, data: await service.referralStats(String(req.query.wallet ?? "")) }); } + catch (error) { forward(next, error); } + }); + router.get("/referrals/history", authenticate, async (req: Request, res: Response, next) => { + try { res.json({ success: true, data: (await service.referralStats(String(req.query.wallet ?? ""))).history }); } + catch (error) { forward(next, error); } + }); + router.post("/referrals/claim", authenticate, async (req: AuthenticatedRequest, res: Response, next) => { + try { res.json({ success: true, data: await service.buildReferralClaim(String(req.user?.stellarAddress ?? req.body?.wallet ?? "")) }); } + catch (error) { forward(next, error); } + }); + + router.post("/waitlist", async (req: Request, res: Response, next) => { + try { res.status(201).json({ success: true, data: await service.registerWaitlist(String(req.body?.wallet ?? ""), String(req.body?.email ?? ""), req.body?.referralCode) }); } + catch (error) { forward(next, error); } + }); + router.get("/waitlist/:wallet", async (req: Request, res: Response, next) => { + try { + const entry = await dataSource.getRepository(WaitlistEntry).findOne({ where: { wallet: req.params.wallet } }); + if (!entry) { res.status(404).json({ success: false, error: "Waitlist entry not found" }); return; } + res.json({ success: true, data: await service.waitlistView(entry as never) }); + } catch (error) { forward(next, error); } + }); + router.post("/waitlist/referral", async (req: Request, res: Response, next) => { + try { const result = await dataSource.getRepository(WaitlistEntry).increment({ referralCode: String(req.body?.referralCode ?? "") }, "referralCount", 1); res.json({ success: true, data: { recorded: Boolean(result.affected) } }); } + catch (error) { forward(next, error); } + }); + + router.post("/invoices/:id/reports", authenticate, async (req: AuthenticatedRequest, res: Response, next) => { + try { res.status(201).json({ success: true, data: await service.createFraudReport({ invoiceId: req.params.id, wallet: String(req.user?.stellarAddress ?? ""), reason: String(req.body?.reason ?? ""), detail: String(req.body?.detail ?? "") }) }); } + catch (error) { forward(next, error); } + }); + + const admin = Router(); + admin.use(authenticate, requireAdmin((process.env.ADMIN_WALLETS ?? "").split(",").map((wallet) => wallet.trim()).filter(Boolean))); + admin.get("/reports", async (req: Request, res: Response, next) => { + try { const page = Math.max(1, Number(req.query.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(req.query.pageSize) || 25)); const [items, total] = await service.listFraudReports(page, pageSize); res.json({ success: true, data: items, pagination: { page, pageSize, total } }); } + catch (error) { forward(next, error); } + }); + admin.patch("/reports/:id", async (req: Request, res: Response, next) => { + try { res.json({ success: true, data: await service.reviewFraudReport(req.params.id, String(req.body?.status) as FraudReportStatus, req.body?.reviewNote) }); } + catch (error) { forward(next, error); } + }); + admin.post("/waitlist/:wallet/approve", async (req: Request, res: Response, next) => { + try { res.json({ success: true, data: await service.approveWaitlist(req.params.wallet) }); } + catch (error) { forward(next, error); } + }); + router.use("/admin", admin); + return router; +} diff --git a/src/services/community-programs.service.ts b/src/services/community-programs.service.ts new file mode 100644 index 0000000..2f319e0 --- /dev/null +++ b/src/services/community-programs.service.ts @@ -0,0 +1,93 @@ +import { DataSource } from "typeorm"; +import Decimal from "decimal.js"; +import { InvoiceFraudReport, FraudReportStatus } from "../models/InvoiceFraudReport.model"; +import { Referral } from "../models/Referral.model"; +import { WaitlistEntry } from "../models/WaitlistEntry.model"; + +export class CommunityProgramsService { + constructor(private readonly dataSource: DataSource) {} + + async createFraudReport(input: { invoiceId: string; wallet: string; reason: string; detail: string }) { + if (!input.reason || input.reason.length > 64 || !input.detail || input.detail.length > 4000) { + throw Object.assign(new Error("reason and detail are required and must be within the allowed length"), { statusCode: 400 }); + } + const repository = this.dataSource.getRepository(InvoiceFraudReport); + const existing = await repository.findOne({ where: { invoiceId: input.invoiceId, reporterWallet: input.wallet } }); + if (existing) throw Object.assign(new Error("A report already exists for this wallet and invoice"), { statusCode: 409 }); + return repository.save(repository.create({ ...input, reporterWallet: input.wallet, status: FraudReportStatus.OPEN, reviewNote: null })); + } + + listFraudReports(page: number, pageSize: number) { + return this.dataSource.getRepository(InvoiceFraudReport).findAndCount({ + order: { createdAt: "DESC" }, skip: (page - 1) * pageSize, take: pageSize, + }); + } + + async reviewFraudReport(id: string, status: FraudReportStatus, reviewNote?: string) { + if (![FraudReportStatus.INVESTIGATING, FraudReportStatus.RESOLVED, FraudReportStatus.DISMISSED].includes(status)) { + throw Object.assign(new Error("Invalid report status"), { statusCode: 400 }); + } + const repository = this.dataSource.getRepository(InvoiceFraudReport); + const report = await repository.findOneBy({ id }); + if (!report) throw Object.assign(new Error("Fraud report not found"), { statusCode: 404 }); + report.status = status; + report.reviewNote = reviewNote?.trim().slice(0, 4000) ?? null; + return repository.save(report); + } + + async registerReferral(referrerWallet: string, refereeWallet: string) { + if (!referrerWallet || !refereeWallet || referrerWallet === refereeWallet) { + throw Object.assign(new Error("A referral must link two different wallets"), { statusCode: 400 }); + } + const repository = this.dataSource.getRepository(Referral); + const existing = await repository.findOne({ where: { refereeWallet } }); + if (existing) return existing; + return repository.save(repository.create({ referrerWallet, refereeWallet, earnedAmount: "0", claimedAmount: "0" })); + } + + async referralStats(wallet: string) { + const referrals = await this.dataSource.getRepository(Referral).find({ where: { referrerWallet: wallet }, order: { createdAt: "DESC" } }); + const earned = referrals.reduce((sum, row) => sum.plus(row.earnedAmount), new Decimal(0)); + const claimed = referrals.reduce((sum, row) => sum.plus(row.claimedAmount), new Decimal(0)); + return { totalReferees: referrals.length, earned: earned.toFixed(4), pending: earned.minus(claimed).toFixed(4), history: referrals }; + } + + async buildReferralClaim(wallet: string) { + const stats = await this.referralStats(wallet); + if (new Decimal(stats.pending).lte(0)) { + throw Object.assign(new Error("No referral rewards are available to claim"), { statusCode: 409 }); + } + return { + wallet, + amount: stats.pending, + // The signing key stays with the wallet client; the API only returns a + // deterministic claim intent for the configured reward contract. + unsignedTransaction: { type: "referral_reward_claim", wallet, amount: stats.pending }, + }; + } + + async registerWaitlist(wallet: string, email: string, referralCode?: string) { + if (!wallet || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + throw Object.assign(new Error("A valid wallet and email are required"), { statusCode: 400 }); + } + const repository = this.dataSource.getRepository(WaitlistEntry); + const existing = await repository.findOne({ where: { wallet } }); + if (existing) return this.waitlistView(existing); + const entry = await repository.save(repository.create({ wallet, email, referralCode: `${wallet.slice(0, 8)}-${Date.now()}`, referralCount: 0, status: "pending" })); + if (referralCode) await repository.increment({ referralCode }, "referralCount", 1); + return this.waitlistView(entry); + } + + async waitlistView(entry: WaitlistEntry) { + const ahead = await this.dataSource.getRepository(WaitlistEntry).count({ where: { status: "pending" } }); + return { wallet: entry.wallet, position: Math.max(1, ahead - entry.referralCount), referralCount: entry.referralCount, referralCode: entry.referralCode, status: entry.status }; + } + + async approveWaitlist(wallet: string) { + const repository = this.dataSource.getRepository(WaitlistEntry); + const entry = await repository.findOne({ where: { wallet } }); + if (!entry) throw Object.assign(new Error("Waitlist entry not found"), { statusCode: 404 }); + entry.status = "approved"; + return repository.save(entry); + } +} diff --git a/src/workers/invoice-maturity.worker.ts b/src/workers/invoice-maturity.worker.ts index 5f1f8b0..8f3d38a 100644 --- a/src/workers/invoice-maturity.worker.ts +++ b/src/workers/invoice-maturity.worker.ts @@ -25,7 +25,7 @@ export interface MaturedInvoice { } export interface MaturedInvoiceRepository { - /** Published or funded invoices whose due date is on or before `asOf`. */ + /** Published or funded invoices whose funding deadline or due date passed. */ findMaturedInvoices(asOf: Date, limit: number): Promise; /** * Moves an under-funded invoice to `failed`. Returns null when the invoice @@ -244,10 +244,10 @@ class TypeOrmMaturedInvoiceRepository implements MaturedInvoiceRepository { async findMaturedInvoices(asOf: Date, limit: number): Promise { const rows = await this.dataSource.getRepository(Invoice).find({ select: { id: true, status: true, amount: true }, - where: { - status: In([InvoiceStatus.PUBLISHED, InvoiceStatus.FUNDED]), - dueDate: LessThanOrEqual(toDateOnly(asOf) as unknown as Date), - }, + where: [ + { status: In([InvoiceStatus.PUBLISHED, InvoiceStatus.FUNDED]), dueDate: LessThanOrEqual(toDateOnly(asOf) as unknown as Date) }, + { status: InvoiceStatus.PUBLISHED, fundingDeadline: LessThanOrEqual(asOf) }, + ], order: { dueDate: "ASC" }, take: limit, });