From c447a0a9a18c96d188f939a50e188997b88f3644 Mon Sep 17 00:00:00 2001 From: Yentec Date: Thu, 28 May 2026 12:07:40 +0200 Subject: [PATCH 1/3] chore(deploy): run migrations on container start --- Dockerfile | 10 ++++++---- docker-compose.yml | 18 ++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- scripts/start.sh | 10 ++++++++++ 5 files changed, 37 insertions(+), 7 deletions(-) create mode 100755 scripts/start.sh diff --git a/Dockerfile b/Dockerfile index 51785c5..84badee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,12 +6,13 @@ FROM node:22-alpine AS build WORKDIR /app COPY package.json package-lock.json ./ - +COPY prisma ./prisma +RUN npm install -g npm@11 RUN npm ci COPY . . -RUN npx prisma generate \ +RUN DATABASE_URL="postgresql://build:build@localhost:5432/build" npx prisma generate \ && npm run build \ && npm prune --omit=dev @@ -25,8 +26,9 @@ ENV NODE_ENV=production COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/dist ./dist COPY --from=build /app/prisma ./prisma +COPY --from=build /app/prisma.config.ts ./ COPY --from=build /app/package.json ./ +COPY --from=build /app/scripts ./scripts EXPOSE 3000 - -CMD ["node", "dist/server.js"] \ No newline at end of file +CMD ["sh", "scripts/start.sh"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 7fbce60..49c3593 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,22 @@ services: + app: + build: . + container_name: linkforge-app + restart: unless-stopped + ports: + - '3000:3000' + environment: + DATABASE_URL: postgresql://linkforge:linkforge@postgres:5432/linkforge + REDIS_URL: redis://redis:6379 + JWT_SECRET: dev-only-change-me-to-a-32-char-minimum-secret + IP_HASH_SALT: dev-only-16-chars-min + BASE_URL: http://localhost:3000 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + postgres: image: postgres:16-alpine container_name: linkforge-postgres diff --git a/package-lock.json b/package-lock.json index fe920f6..9e727d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "0.6.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "0.6.0", + "version": "0.6.1", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index 172eb75..2668293 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "0.6.0", + "version": "0.6.1", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100755 index 0000000..11f2cd4 --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -e + +# Apply database migrations before booting the API. +# Safe to run on every cold start: prisma migrate deploy is idempotent. +echo "Applying database migrations..." +npx prisma migrate deploy + +echo "Starting LinkForge..." +exec node dist/server.js \ No newline at end of file From 7e0d1d7efc3abf92c600b3f7edae47ec65d9a860 Mon Sep 17 00:00:00 2001 From: Yentec Date: Thu, 28 May 2026 12:15:10 +0200 Subject: [PATCH 2/3] chore(deploy): add render blueprint for web service --- package-lock.json | 4 ++-- package.json | 2 +- render.yaml | 30 ++++++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 render.yaml diff --git a/package-lock.json b/package-lock.json index 9e727d2..9763870 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "0.6.1", + "version": "0.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "0.6.1", + "version": "0.6.2", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index 2668293..d425276 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "0.6.1", + "version": "0.6.2", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..9071ae7 --- /dev/null +++ b/render.yaml @@ -0,0 +1,30 @@ +# Blueprint for Render. Connect this repo in the Render dashboard and the +# web service is provisioned from this file. Postgres and Redis live on Neon +# and Upstash respectively; their connection strings are injected via env vars. +services: + - type: web + name: linkforge + runtime: docker + plan: free + region: frankfurt + healthCheckPath: /health + autoDeploy: true + envVars: + - key: NODE_ENV + value: production + - key: HOST + value: 0.0.0.0 + - key: PORT + value: 3000 + - key: LOG_LEVEL + value: info + - key: BASE_URL + sync: false # set in Render dashboard to https://.onrender.com + - key: DATABASE_URL + sync: false # paste the Neon pooled connection string + - key: REDIS_URL + sync: false # paste the Upstash rediss:// URL + - key: JWT_SECRET + generateValue: true + - key: IP_HASH_SALT + generateValue: true From 70718a4947c741bbcd8e7a1ff09e9f56097280db Mon Sep 17 00:00:00 2001 From: Yentec Date: Thu, 28 May 2026 12:30:30 +0200 Subject: [PATCH 3/3] chore(deploy): add demo seed with weighted click history --- package-lock.json | 4 +-- package.json | 2 +- prisma.config.ts | 3 +++ prisma/seed.ts | 65 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 prisma/seed.ts diff --git a/package-lock.json b/package-lock.json index 9763870..e74d2b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "0.6.2", + "version": "0.6.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "0.6.2", + "version": "0.6.3", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index d425276..18237c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "0.6.2", + "version": "0.6.3", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", diff --git a/prisma.config.ts b/prisma.config.ts index 4272629..e631ed5 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -2,6 +2,9 @@ import { defineConfig, env } from 'prisma/config'; import 'dotenv/config'; export default defineConfig({ + migrations: { + seed: 'tsx prisma/seed.ts', + }, datasource: { url: env('DATABASE_URL'), }, diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..f3f9f5e --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,65 @@ +import { PrismaClient } from '@prisma/client'; +import { PrismaPg } from '@prisma/adapter-pg'; +import * as argon2 from 'argon2'; +import 'dotenv/config'; +import { createHash, randomBytes } from 'node:crypto'; + +const prisma = new PrismaClient({ + adapter: new PrismaPg(process.env['DATABASE_URL']!), +}); + +const DEMO_EMAIL = 'demo@linkforge.dev'; +const DEMO_PASSWORD = 'DemoUser2026!'; +const COUNTRIES = ['FR', 'US', 'DE', 'GB', 'JP', 'BR'] as const; +const REFERRERS = ['google.com', 'twitter.com', 'news.ycombinator.com', null] as const; +const DEVICES = ['desktop', 'mobile', 'tablet'] as const; +const BROWSERS = ['Chrome', 'Safari', 'Firefox', 'Edge'] as const; + +function pick(arr: readonly T[]): T { + return arr[Math.floor(Math.random() * arr.length)] as T; +} + +async function main(): Promise { + console.log('Seeding demo data...'); + + // Idempotent: re-running the seed wipes the demo account and rebuilds it. + await prisma.user.deleteMany({ where: { email: DEMO_EMAIL } }); + + const user = await prisma.user.create({ + data: { email: DEMO_EMAIL, password: await argon2.hash(DEMO_PASSWORD) }, + }); + + const links = await Promise.all( + [ + { code: 'launch1', target: 'https://example.com/launch' }, + { code: 'docs01a', target: 'https://example.com/docs' }, + { code: 'blogpst', target: 'https://example.com/blog/announcement' }, + ].map((l) => prisma.link.create({ data: { ...l, userId: user.id } })), + ); + + // 90 days of clicks, weighted toward recent days. + const now = Date.now(); + const clickData = Array.from({ length: 600 }, () => { + const daysAgo = Math.floor(Math.random() ** 1.6 * 90); + return { + linkId: pick(links).id, + country: pick(COUNTRIES), + deviceType: pick(DEVICES), + browser: pick(BROWSERS), + referrerHost: pick(REFERRERS), + ipHash: createHash('sha256').update(randomBytes(16)).digest('hex').slice(0, 16), + createdAt: new Date(now - daysAgo * 86_400_000 - Math.random() * 86_400_000), + }; + }); + await prisma.click.createMany({ data: clickData }); + + console.log(`Done. Demo user: ${DEMO_EMAIL} / ${DEMO_PASSWORD}`); + console.log(`Created ${links.length} links and ${clickData.length} clicks.`); +} + +main() + .catch((err) => { + console.error(err); + process.exit(1); + }) + .finally(() => void prisma.$disconnect());