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
199 changes: 193 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "linkforge",
"version": "0.4.0",
"version": "0.5.0",
"description": "URL shortener API with authentication, API keys, async click tracking and analytics.",
"type": "module",
"license": "MIT",
Expand Down Expand Up @@ -34,6 +34,7 @@
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"argon2": "^0.44.0",
"bullmq": "^5.77.6",
"dotenv": "^17.4.2",
"fastify": "^5.8.5",
"ioredis": "^5.10.1",
Expand Down
19 changes: 19 additions & 0 deletions prisma/migrations/20260527080957_clicks_model/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "clicks" (
"id" UUID NOT NULL,
"linkId" UUID NOT NULL,
"country" TEXT,
"deviceType" TEXT NOT NULL,
"browser" TEXT,
"referrerHost" TEXT,
"ipHash" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "clicks_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "clicks_linkId_createdAt_idx" ON "clicks"("linkId", "createdAt");

-- AddForeignKey
ALTER TABLE "clicks" ADD CONSTRAINT "clicks_linkId_fkey" FOREIGN KEY ("linkId") REFERENCES "links"("id") ON DELETE CASCADE ON UPDATE CASCADE;
18 changes: 18 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,25 @@ model Link {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

clicks Click[]

// Cursor pagination scans by (userId, createdAt, id).
@@index([userId, createdAt, id])
@@map("links")
}

model Click {
id String @id @default(uuid()) @db.Uuid
linkId String @db.Uuid
link Link @relation(fields: [linkId], references: [id], onDelete: Cascade)
country String?
deviceType String
browser String?
referrerHost String?
ipHash String
createdAt DateTime @default(now())

// Analytics queries scan by link over a time window.
@@index([linkId, createdAt])
@@map("clicks")
}
4 changes: 4 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { healthRoutes } from '@/modules/health/health.routes';
import { authRoutes } from './modules/auth/auth.routes';
import { apiKeyRoutes } from './modules/api-keys/api-keys.routes';
import { linkRoutes } from './modules/links/links.routes';
import { redirectRoutes } from './modules/redirect/redirect.routes';

/**
* Builds a fully configured Fastify instance without starting the server.
Expand Down Expand Up @@ -36,5 +37,8 @@ export async function buildApp(): Promise<FastifyInstance> {
{ prefix: '/v1' },
);

// Public redirect catch-all. Must be registered last.
await app.register(redirectRoutes);

return app;
}
24 changes: 24 additions & 0 deletions src/modules/redirect/redirect.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { enqueueClick } from '@/modules/tracking/tracking.queue';
import type { RedirectService } from './redirect.service';
import { redirectParamsSchema } from './redirect.schemas';

export function createRedirectController(service: RedirectService) {
return {
redirect: async (request: FastifyRequest, reply: FastifyReply) => {
const { code } = redirectParamsSchema.parse(request.params);
const link = await service.resolve(code);

// Fire-and-forget: tracking must never block or fail the redirect.
void enqueueClick({
linkId: link.id,
ip: request.ip,
userAgent: request.headers['user-agent'] ?? '',
referrer: request.headers.referer ?? null,
});

// Fastify 5 signature: redirect(url, code?).
return reply.redirect(link.target, 302);
},
};
}
Loading
Loading