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:
Alireza Hassani
2026-07-26 12:00:10 +03:30
co-authored by Cursor
parent 3faeb9bc0d
commit 723948cd5e
5 changed files with 64 additions and 20 deletions
@@ -0,0 +1,25 @@
-- Opaque public invoice id (unguessable link token; not the sequential PK)
ALTER TABLE invoices
ADD COLUMN IF NOT EXISTS public_id VARCHAR(32);
-- Backfill existing rows with unique 12-digit codes
DO $$
DECLARE
r RECORD;
candidate TEXT;
BEGIN
FOR r IN SELECT id FROM invoices WHERE public_id IS NULL LOOP
LOOP
candidate := lpad((floor(random() * 900000000000) + 100000000000)::bigint::text, 12, '0');
EXIT WHEN NOT EXISTS (SELECT 1 FROM invoices WHERE public_id = candidate);
END LOOP;
UPDATE invoices SET public_id = candidate WHERE id = r.id;
END LOOP;
END $$;
ALTER TABLE invoices
ALTER COLUMN public_id SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_invoices_public_id
ON invoices (public_id);
+6 -5
View File
@@ -156,6 +156,7 @@ Example super admin: `+989121111111` / `password`
| `037_invoice_name.sql` | Optional `invoices.name` | | `037_invoice_name.sql` | Optional `invoices.name` |
| `038_invoice_templates.sql` | Full invoice templates + key points/accounts on invoices | | `038_invoice_templates.sql` | Full invoice templates + key points/accounts on invoices |
| `039_invoice_account_holder.sql` | `account_holder_name` on invoice / template accounts | | `039_invoice_account_holder.sql` | `account_holder_name` on invoice / template accounts |
| `040_invoice_public_id.sql` | Opaque `public_id` for unguessable public invoice links |
Docker mounts `./database/migrations` into Postgres init — migrations run automatically only on **first** volume creation. Use `migrate.sh` for subsequent migrations. Docker mounts `./database/migrations` into Postgres init — migrations run automatically only on **first** volume creation. Use `migrate.sh` for subsequent migrations.
@@ -588,17 +589,17 @@ Super admins issue invoices **to** a business. Schema is ready for future busine
| GET/PATCH/DELETE | `/invoice-templates/:templateId` | | GET/PATCH/DELETE | `/invoice-templates/:templateId` |
| GET/POST | `/businesses/:businessId/invoices` | | GET/POST | `/businesses/:businessId/invoices` |
| GET/PATCH/DELETE | `/businesses/:businessId/invoices/:invoiceId` | | GET/PATCH/DELETE | `/businesses/:businessId/invoices/:invoiceId` |
| GET | `/public/invoices/:invoiceId` (no auth; issued/paid only) | | GET | `/public/invoices/:publicId` (no auth; issued/paid only; opaque 12-digit id) |
Auth (admin routes): `JwtAuthGuard` + service `assertSuperAdmin`. Auth (admin routes): `JwtAuthGuard` + service `assertSuperAdmin`.
Serialized platform invoices include `publicUrl`: `https://{INVOICE_PUBLIC_DOMAIN}/invoices/{id}` (default `meshkee.com`), or `{INVOICE_PUBLIC_BASE_URL}/invoices/{id}` when set. Accounts include optional `accountHolderName`. Serialized platform invoices include `publicId` + `publicUrl`: `https://{INVOICE_PUBLIC_DOMAIN}/invoices/{publicId}` (default `meshkee.com`), or `{INVOICE_PUBLIC_BASE_URL}/invoices/{publicId}` when set. Accounts include optional `accountHolderName`.
Public HTML viewer lives in the dashboards super-admin SPA (`/invoices/:id`); API serves JSON via `/public/invoices/:id`. Public HTML viewer lives in the dashboards super-admin SPA (`/invoices/:publicId`); API serves JSON via `/public/invoices/:publicId` (sequential PK is not accepted).
Permissions seeded for future business dashboard: `invoices.*`, `invoice_templates.*`. Permissions seeded for future business dashboard: `invoices.*`, `invoice_templates.*`.
Module: `src/invoices/` · Migrations: `036`, `037`, `038`, `039` Module: `src/invoices/` · Migrations: `036``040`
--- ---
@@ -646,7 +647,7 @@ Follow the pattern in `CategoryVariationsService` / `CategoryTechnicalFormServic
| Prisma schema | `prisma/schema.prisma` | | Prisma schema | `prisma/schema.prisma` |
| Env template | `.env.example` (`INVOICE_PUBLIC_DOMAIN` / optional `INVOICE_PUBLIC_BASE_URL`) | | Env template | `.env.example` (`INVOICE_PUBLIC_DOMAIN` / optional `INVOICE_PUBLIC_BASE_URL`) |
| Invoices module | `src/invoices/` | | Invoices module | `src/invoices/` |
| Invoice migrations | `database/migrations/036_invoices.sql``039_invoice_account_holder.sql` | | Invoice migrations | `database/migrations/036_invoices.sql``040_invoice_public_id.sql` |
| Docker services | `docker-compose.yml` | | Docker services | `docker-compose.yml` |
| Dev seed data | `database/seeds/001_sample_data.sql` | | Dev seed data | `database/seeds/001_sample_data.sql` |
| Postman | `postman/Meshkee-CMS-Auth.postman_collection.json` | | Postman | `postman/Meshkee-CMS-Auth.postman_collection.json` |
+1
View File
@@ -1242,6 +1242,7 @@ model InvoiceTemplateAccount {
model Invoice { model Invoice {
id BigInt @id @default(autoincrement()) id BigInt @id @default(autoincrement())
publicId String @unique(map: "idx_invoices_public_id") @map("public_id") @db.VarChar(32)
businessId BigInt @map("business_id") businessId BigInt @map("business_id")
ownerScope InvoiceOwnerScope @default(platform) @map("owner_scope") ownerScope InvoiceOwnerScope @default(platform) @map("owner_scope")
issuerBusinessId BigInt? @map("issuer_business_id") issuerBusinessId BigInt? @map("issuer_business_id")
+3 -3
View File
@@ -144,8 +144,8 @@ export class InvoicesController {
} }
/** Public invoice show page (no auth). Issued / paid platform invoices only. */ /** Public invoice show page (no auth). Issued / paid platform invoices only. */
@Get('public/invoices/:invoiceId') @Get('public/invoices/:publicId')
getPublic(@Param('invoiceId') invoiceId: string) { getPublic(@Param('publicId') publicId: string) {
return this.service.getPublicInvoice(invoiceId); return this.service.getPublicInvoice(publicId);
} }
} }
+29 -12
View File
@@ -4,6 +4,7 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { randomInt } from 'crypto';
import { InvoiceOwnerScope, InvoiceStatus, Prisma } from '@prisma/client'; import { InvoiceOwnerScope, InvoiceStatus, Prisma } from '@prisma/client';
import { AuthUser } from '../auth/auth.types'; import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service'; import { PermissionsService } from '../auth/permissions.service';
@@ -22,6 +23,10 @@ import {
UpdateInvoiceTemplateDto, UpdateInvoiceTemplateDto,
} from './dto/invoice.dto'; } 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() @Injectable()
export class InvoicesService { export class InvoicesService {
constructor( constructor(
@@ -29,6 +34,17 @@ export class InvoicesService {
private readonly permissions: PermissionsService, 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) { private async assertSuperAdmin(actor: AuthUser) {
if (!(await this.permissions.isSuperAdmin(actor.id))) { if (!(await this.permissions.isSuperAdmin(actor.id))) {
throw new ForbiddenException('Super admin access required'); 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(); const base = process.env.INVOICE_PUBLIC_BASE_URL?.trim();
if (base) { if (base) {
return `${base.replace(/\/$/, '')}/invoices/${invoiceId.toString()}`; return `${base.replace(/\/$/, '')}/invoices/${publicId}`;
} }
const domain = process.env.INVOICE_PUBLIC_DOMAIN?.trim() || 'meshkee.com'; const domain = process.env.INVOICE_PUBLIC_DOMAIN?.trim() || 'meshkee.com';
return `https://${domain}/invoices/${invoiceId.toString()}`; return `https://${domain}/invoices/${publicId}`;
} }
private serializeInvoice( private serializeInvoice(
row: { row: {
id: bigint; id: bigint;
publicId: string;
businessId: bigint; businessId: bigint;
ownerScope: InvoiceOwnerScope; ownerScope: InvoiceOwnerScope;
issuerBusinessId: bigint | null; issuerBusinessId: bigint | null;
@@ -301,6 +318,7 @@ export class InvoicesService {
return { return {
id: row.id.toString(), id: row.id.toString(),
publicId: row.publicId,
businessId: row.businessId.toString(), businessId: row.businessId.toString(),
ownerScope: row.ownerScope, ownerScope: row.ownerScope,
issuerBusinessId: row.issuerBusinessId?.toString() ?? null, issuerBusinessId: row.issuerBusinessId?.toString() ?? null,
@@ -311,7 +329,7 @@ export class InvoicesService {
invoiceTemplateId: row.invoiceTemplateId?.toString() ?? null, invoiceTemplateId: row.invoiceTemplateId?.toString() ?? null,
publicUrl: publicUrl:
row.ownerScope === InvoiceOwnerScope.platform row.ownerScope === InvoiceOwnerScope.platform
? this.platformInvoicePublicUrl(row.id) ? this.platformInvoicePublicUrl(row.publicId)
: null, : null,
issuedBy: row.issuedBy?.toString() ?? null, issuedBy: row.issuedBy?.toString() ?? null,
issuedAt: row.issuedAt, issuedAt: row.issuedAt,
@@ -715,17 +733,15 @@ export class InvoicesService {
// --- Public invoice viewer (platform) --- // --- Public invoice viewer (platform) ---
async getPublicInvoice(invoiceIdRaw: string) { async getPublicInvoice(publicIdRaw: string) {
let invoiceId: bigint; const publicId = publicIdRaw.trim();
try { if (!/^\d{6,32}$/.test(publicId)) {
invoiceId = BigInt(invoiceIdRaw);
} catch {
throw new NotFoundException('Invoice not found'); throw new NotFoundException('Invoice not found');
} }
const row = await this.prisma.invoice.findFirst({ const row = await this.prisma.invoice.findFirst({
where: { where: {
id: invoiceId, publicId,
ownerScope: InvoiceOwnerScope.platform, ownerScope: InvoiceOwnerScope.platform,
status: { in: [InvoiceStatus.issued, InvoiceStatus.paid] }, status: { in: [InvoiceStatus.issued, InvoiceStatus.paid] },
}, },
@@ -737,9 +753,9 @@ export class InvoicesService {
} }
const serialized = this.serializeInvoice(row, true); const serialized = this.serializeInvoice(row, true);
// Public payload: no internal notes / issuer identity // Public payload: no internal notes / issuer / sequential id
return { return {
id: serialized.id, publicId: serialized.publicId,
status: serialized.status, status: serialized.status,
name: serialized.name, name: serialized.name,
topText: serialized.topText, topText: serialized.topText,
@@ -849,6 +865,7 @@ export class InvoicesService {
const row = await this.prisma.invoice.create({ const row = await this.prisma.invoice.create({
data: { data: {
publicId: await this.generateUniquePublicId(),
businessId, businessId,
ownerScope: InvoiceOwnerScope.platform, ownerScope: InvoiceOwnerScope.platform,
status: dto.status ?? InvoiceStatus.issued, status: dto.status ?? InvoiceStatus.issued,