Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
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";
Expand Down Expand Up @@ -413,6 +413,7 @@

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));
Expand Down Expand Up @@ -559,7 +560,7 @@
if (issuerReputationService && authService) {
app.use(
"/api/v1",
createIssuerReputationRouter({

Check failure on line 563 in src/app.ts

View workflow job for this annotation

GitHub Actions / Build

Cannot find name 'createIssuerReputationRouter'.

Check failure on line 563 in src/app.ts

View workflow job for this annotation

GitHub Actions / Type Check

Cannot find name 'createIssuerReputationRouter'.
issuerReputationService,
authService,
adminWallets: config?.admin?.wallets || [],
Expand Down
48 changes: 48 additions & 0 deletions src/migrations/1754000000000-CreateCommunityProgramTables.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from "typeorm";

export class CreateCommunityProgramTables1754000000000 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.dropTable("waitlist_entries", true);
await queryRunner.dropTable("referrals", true);
await queryRunner.dropTable("invoice_fraud_reports", true);
}
}
40 changes: 40 additions & 0 deletions src/models/InvoiceFraudReport.model.ts
Original file line number Diff line number Diff line change
@@ -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;
}
24 changes: 24 additions & 0 deletions src/models/Referral.model.ts
Original file line number Diff line number Diff line change
@@ -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;
}
30 changes: 30 additions & 0 deletions src/models/WaitlistEntry.model.ts
Original file line number Diff line number Diff line change
@@ -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;
}
70 changes: 70 additions & 0 deletions src/routes/community-programs.routes.ts
Original file line number Diff line number Diff line change
@@ -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 } });

Check failure on line 39 in src/routes/community-programs.routes.ts

View workflow job for this annotation

GitHub Actions / Build

Type '{ wallet: string | string[]; }' is not assignable to type 'FindOptionsWhere<WaitlistEntry> | FindOptionsWhere<WaitlistEntry>[] | undefined'.

Check failure on line 39 in src/routes/community-programs.routes.ts

View workflow job for this annotation

GitHub Actions / Type Check

Type '{ wallet: string | string[]; }' is not assignable to type 'FindOptionsWhere<WaitlistEntry> | FindOptionsWhere<WaitlistEntry>[] | undefined'.
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 ?? "") }) }); }

Check failure on line 50 in src/routes/community-programs.routes.ts

View workflow job for this annotation

GitHub Actions / Build

Type 'string | string[]' is not assignable to type 'string'.

Check failure on line 50 in src/routes/community-programs.routes.ts

View workflow job for this annotation

GitHub Actions / Type Check

Type 'string | string[]' is not assignable to type 'string'.
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) }); }

Check failure on line 61 in src/routes/community-programs.routes.ts

View workflow job for this annotation

GitHub Actions / Build

Argument of type 'string | string[]' is not assignable to parameter of type 'string'.

Check failure on line 61 in src/routes/community-programs.routes.ts

View workflow job for this annotation

GitHub Actions / Type Check

Argument of type 'string | string[]' is not assignable to parameter of type 'string'.
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) }); }

Check failure on line 65 in src/routes/community-programs.routes.ts

View workflow job for this annotation

GitHub Actions / Build

Argument of type 'string | string[]' is not assignable to parameter of type 'string'.

Check failure on line 65 in src/routes/community-programs.routes.ts

View workflow job for this annotation

GitHub Actions / Type Check

Argument of type 'string | string[]' is not assignable to parameter of type 'string'.
catch (error) { forward(next, error); }
});
router.use("/admin", admin);
return router;
}
93 changes: 93 additions & 0 deletions src/services/community-programs.service.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
10 changes: 5 additions & 5 deletions src/workers/invoice-maturity.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MaturedInvoice[]>;
/**
* Moves an under-funded invoice to `failed`. Returns null when the invoice
Expand Down Expand Up @@ -244,10 +244,10 @@ class TypeOrmMaturedInvoiceRepository implements MaturedInvoiceRepository {
async findMaturedInvoices(asOf: Date, limit: number): Promise<MaturedInvoice[]> {
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,
});
Expand Down
Loading