mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
Add opaque 12-digit publicId for unguessable invoice links.
Public viewer and URLs use publicId instead of sequential primary keys. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
3faeb9bc0d
commit
723948cd5e
@@ -144,8 +144,8 @@ export class InvoicesController {
|
||||
}
|
||||
|
||||
/** Public invoice show page (no auth). Issued / paid platform invoices only. */
|
||||
@Get('public/invoices/:invoiceId')
|
||||
getPublic(@Param('invoiceId') invoiceId: string) {
|
||||
return this.service.getPublicInvoice(invoiceId);
|
||||
@Get('public/invoices/:publicId')
|
||||
getPublic(@Param('publicId') publicId: string) {
|
||||
return this.service.getPublicInvoice(publicId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { randomInt } from 'crypto';
|
||||
import { InvoiceOwnerScope, InvoiceStatus, Prisma } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
@@ -22,6 +23,10 @@ import {
|
||||
UpdateInvoiceTemplateDto,
|
||||
} from './dto/invoice.dto';
|
||||
|
||||
/** 12-digit unguessable public link token (not the sequential PK). */
|
||||
const PUBLIC_ID_MIN = 100_000_000_000;
|
||||
const PUBLIC_ID_MAX = 999_999_999_999;
|
||||
|
||||
@Injectable()
|
||||
export class InvoicesService {
|
||||
constructor(
|
||||
@@ -29,6 +34,17 @@ export class InvoicesService {
|
||||
private readonly permissions: PermissionsService,
|
||||
) {}
|
||||
|
||||
private async generateUniquePublicId(): Promise<string> {
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
const candidate = String(randomInt(PUBLIC_ID_MIN, PUBLIC_ID_MAX + 1));
|
||||
const existing = await this.prisma.invoice.findUnique({
|
||||
where: { publicId: candidate },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) return candidate;
|
||||
}
|
||||
throw new BadRequestException('Unable to allocate a public invoice id');
|
||||
}
|
||||
private async assertSuperAdmin(actor: AuthUser) {
|
||||
if (!(await this.permissions.isSuperAdmin(actor.id))) {
|
||||
throw new ForbiddenException('Super admin access required');
|
||||
@@ -227,18 +243,19 @@ export class InvoicesService {
|
||||
};
|
||||
}
|
||||
|
||||
private platformInvoicePublicUrl(invoiceId: bigint) {
|
||||
private platformInvoicePublicUrl(publicId: string) {
|
||||
const base = process.env.INVOICE_PUBLIC_BASE_URL?.trim();
|
||||
if (base) {
|
||||
return `${base.replace(/\/$/, '')}/invoices/${invoiceId.toString()}`;
|
||||
return `${base.replace(/\/$/, '')}/invoices/${publicId}`;
|
||||
}
|
||||
const domain = process.env.INVOICE_PUBLIC_DOMAIN?.trim() || 'meshkee.com';
|
||||
return `https://${domain}/invoices/${invoiceId.toString()}`;
|
||||
return `https://${domain}/invoices/${publicId}`;
|
||||
}
|
||||
|
||||
private serializeInvoice(
|
||||
row: {
|
||||
id: bigint;
|
||||
publicId: string;
|
||||
businessId: bigint;
|
||||
ownerScope: InvoiceOwnerScope;
|
||||
issuerBusinessId: bigint | null;
|
||||
@@ -301,6 +318,7 @@ export class InvoicesService {
|
||||
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
publicId: row.publicId,
|
||||
businessId: row.businessId.toString(),
|
||||
ownerScope: row.ownerScope,
|
||||
issuerBusinessId: row.issuerBusinessId?.toString() ?? null,
|
||||
@@ -311,7 +329,7 @@ export class InvoicesService {
|
||||
invoiceTemplateId: row.invoiceTemplateId?.toString() ?? null,
|
||||
publicUrl:
|
||||
row.ownerScope === InvoiceOwnerScope.platform
|
||||
? this.platformInvoicePublicUrl(row.id)
|
||||
? this.platformInvoicePublicUrl(row.publicId)
|
||||
: null,
|
||||
issuedBy: row.issuedBy?.toString() ?? null,
|
||||
issuedAt: row.issuedAt,
|
||||
@@ -715,17 +733,15 @@ export class InvoicesService {
|
||||
|
||||
// --- Public invoice viewer (platform) ---
|
||||
|
||||
async getPublicInvoice(invoiceIdRaw: string) {
|
||||
let invoiceId: bigint;
|
||||
try {
|
||||
invoiceId = BigInt(invoiceIdRaw);
|
||||
} catch {
|
||||
async getPublicInvoice(publicIdRaw: string) {
|
||||
const publicId = publicIdRaw.trim();
|
||||
if (!/^\d{6,32}$/.test(publicId)) {
|
||||
throw new NotFoundException('Invoice not found');
|
||||
}
|
||||
|
||||
const row = await this.prisma.invoice.findFirst({
|
||||
where: {
|
||||
id: invoiceId,
|
||||
publicId,
|
||||
ownerScope: InvoiceOwnerScope.platform,
|
||||
status: { in: [InvoiceStatus.issued, InvoiceStatus.paid] },
|
||||
},
|
||||
@@ -737,9 +753,9 @@ export class InvoicesService {
|
||||
}
|
||||
|
||||
const serialized = this.serializeInvoice(row, true);
|
||||
// Public payload: no internal notes / issuer identity
|
||||
// Public payload: no internal notes / issuer / sequential id
|
||||
return {
|
||||
id: serialized.id,
|
||||
publicId: serialized.publicId,
|
||||
status: serialized.status,
|
||||
name: serialized.name,
|
||||
topText: serialized.topText,
|
||||
@@ -849,6 +865,7 @@ export class InvoicesService {
|
||||
|
||||
const row = await this.prisma.invoice.create({
|
||||
data: {
|
||||
publicId: await this.generateUniquePublicId(),
|
||||
businessId,
|
||||
ownerScope: InvoiceOwnerScope.platform,
|
||||
status: dto.status ?? InvoiceStatus.issued,
|
||||
|
||||
Reference in New Issue
Block a user