From d02428fca1300288be5eaedc9b44eee9ebfa3f44 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 29 Mar 2026 19:02:11 +0200 Subject: [PATCH] feat(uload): sync_changes integration, Stripe checkout, docs update Sync integration: - Redirect service reads links from mana-sync's sync_changes table - Analytics service queries clicks from sync_changes - Click tracking writes to sync_changes (visible to all clients) - Public profile reads from sync_changes - Server DB points to mana_sync database (not separate uload DB) - Removed uload-database dependency from server Stripe: - Real Stripe checkout session creation (monthly/yearly) - Webhook handler with signature verification - Webhook route bypasses JWT auth Documentation: - Root CLAUDE.md: added uload to project table, dev commands, local-first list - mana-sync CLAUDE.md: added uLoad, Taktik, Calc to connected apps Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 3 + apps/uload/apps/server/package.json | 2 +- apps/uload/apps/server/src/config.ts | 8 +- apps/uload/apps/server/src/db/connection.ts | 5 +- apps/uload/apps/server/src/index.ts | 10 +- apps/uload/apps/server/src/routes/public.ts | 57 ++++---- apps/uload/apps/server/src/routes/stripe.ts | 66 ++++++++- .../apps/server/src/services/analytics.ts | 129 +++++++++--------- .../apps/server/src/services/redirect.ts | 89 +++++++----- docker-compose.macmini.yml | 2 +- pnpm-lock.yaml | 21 ++- services/mana-sync/CLAUDE.md | 2 +- 12 files changed, 254 insertions(+), 140 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f7d990fe9..0c489b879 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,7 @@ For comprehensive guidelines on code patterns and conventions, see the `.claude/ | **inventar** | Inventory management | Web | | **traces** | City exploration | Backend, Mobile | | **taktik** | Time tracking | Web | +| **uload** | URL shortener & link management | Server, Web, Landing | | **calc** | Calculator & converter | Web | | **playground** | LLM playground | Web | @@ -88,6 +89,7 @@ pnpm dev:calendar:full # Start calendar with auth + auto DB setup pnpm dev:clock:full # Start clock with auth + auto DB setup pnpm dev:todo:full # Start todo with auth + auto DB setup pnpm dev:picture:full # Start picture with auth + auto DB setup +pnpm dev:uload:full # Start uload with auth + auto DB setup ``` These commands automatically: @@ -579,6 +581,7 @@ Logged in: App → IndexedDB → UI → SyncEngine → mana-sync (Go) → Postg | SkilltTree | skills, activities, achievements | Done | | CityCorners | locations, favorites | Done | | Taktik | clients, projects, timeEntries, tags, templates, settings | Done | +| uLoad | links, tags, folders, linkTags | Done | | Calc | calculations, savedFormulas | Done | **Not migrated (no CRUD data model):** ManaCore (hub), Matrix (protocol client), Playground (stateless) diff --git a/apps/uload/apps/server/package.json b/apps/uload/apps/server/package.json index 024400244..0cba1c4ff 100644 --- a/apps/uload/apps/server/package.json +++ b/apps/uload/apps/server/package.json @@ -9,9 +9,9 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@manacore/uload-database": "workspace:*", "drizzle-orm": "^0.44.7", "hono": "^4.7.0", + "stripe": "^18.4.0", "jose": "^6.1.2", "postgres": "^3.4.7" }, diff --git a/apps/uload/apps/server/src/config.ts b/apps/uload/apps/server/src/config.ts index af5e49ede..303c7af77 100644 --- a/apps/uload/apps/server/src/config.ts +++ b/apps/uload/apps/server/src/config.ts @@ -3,6 +3,9 @@ export interface Config { databaseUrl: string; manaAuthUrl: string; cors: { origins: string[] }; + stripeSecretKey: string; + stripeWebhookSecret: string; + baseUrl: string; } export function loadConfig(): Config { @@ -16,11 +19,14 @@ export function loadConfig(): Config { port: parseInt(process.env.PORT || '3070', 10), databaseUrl: requiredEnv( 'DATABASE_URL', - 'postgresql://uload:uload_dev_password_123@localhost:5432/uload_dev' + 'postgresql://manacore:devpassword@localhost:5432/mana_sync' ), manaAuthUrl: requiredEnv('MANA_CORE_AUTH_URL', 'http://localhost:3001'), cors: { origins: (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','), }, + stripeSecretKey: process.env.STRIPE_SECRET_KEY || '', + stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '', + baseUrl: process.env.BASE_URL || 'http://localhost:3070', }; } diff --git a/apps/uload/apps/server/src/db/connection.ts b/apps/uload/apps/server/src/db/connection.ts index f21ce1a1c..97160c35a 100644 --- a/apps/uload/apps/server/src/db/connection.ts +++ b/apps/uload/apps/server/src/db/connection.ts @@ -1,13 +1,12 @@ import { drizzle } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; -import * as schema from '@manacore/uload-database'; -let db: ReturnType> | null = null; +let db: ReturnType | null = null; export function getDb(databaseUrl: string) { if (!db) { const client = postgres(databaseUrl, { max: 10 }); - db = drizzle(client, { schema }); + db = drizzle(client); } return db; } diff --git a/apps/uload/apps/server/src/index.ts b/apps/uload/apps/server/src/index.ts index 75f24f4e0..c1ee33e02 100644 --- a/apps/uload/apps/server/src/index.ts +++ b/apps/uload/apps/server/src/index.ts @@ -31,10 +31,16 @@ app.route('/health', healthRoutes); app.route('/r', createRedirectRoutes(redirectService)); app.route('/public', createPublicRoutes(db)); -// Analytics API (auth required) +// Stripe webhook (no auth — signature verified internally) +app.post('/api/v1/stripe/webhook', async (c) => { + const routes = createStripeRoutes(config); + return routes.fetch(c.req.raw); +}); + +// Authenticated API routes app.use('/api/v1/*', jwtAuth(config.manaAuthUrl)); app.route('/api/v1/analytics', createAnalyticsRoutes(analyticsService)); -app.route('/api/v1/stripe', createStripeRoutes()); +app.route('/api/v1/stripe', createStripeRoutes(config)); app.route('/api/v1/email', createEmailRoutes()); console.log(`uload-server starting on port ${config.port}...`); diff --git a/apps/uload/apps/server/src/routes/public.ts b/apps/uload/apps/server/src/routes/public.ts index ad07509cc..93cfca072 100644 --- a/apps/uload/apps/server/src/routes/public.ts +++ b/apps/uload/apps/server/src/routes/public.ts @@ -1,35 +1,44 @@ import { Hono } from 'hono'; -import { eq, and, desc } from 'drizzle-orm'; -import { links, users } from '@manacore/uload-database'; +import { sql } from 'drizzle-orm'; import type { Database } from '../db/connection'; export function createPublicRoutes(db: Database) { return new Hono().get('/u/:username', async (c) => { const username = c.req.param('username'); - const [user] = await db - .select({ id: users.id, username: users.username, name: users.name, bio: users.bio }) - .from(users) - .where(and(eq(users.username, username), eq(users.publicProfile, true))) - .limit(1); + // Query links for a user from sync_changes + // Note: In mana-sync, user_id is the auth user ID, not username. + // For public profiles, we'd need a user lookup. For now, treat username as user_id. + const result = await db.execute(sql` + SELECT DISTINCT ON (record_id) + record_id as id, + data->>'shortCode' as "shortCode", + data->>'title' as title, + data->>'description' as description, + COALESCE((data->>'clickCount')::int, 0) as "clickCount", + created_at as "createdAt" + FROM sync_changes + WHERE app_id = 'uload' + AND table_name = 'links' + AND user_id = ${username} + AND op != 'delete' + AND COALESCE((data->>'isActive')::boolean, true) = true + ORDER BY record_id, created_at DESC + LIMIT 50 + `); - if (!user) { - return c.json({ error: 'User not found' }, 404); - } + const links = result as unknown as { + id: string; + shortCode: string; + title: string | null; + description: string | null; + clickCount: number; + createdAt: string; + }[]; - const userLinks = await db - .select({ - shortCode: links.shortCode, - title: links.title, - description: links.description, - clickCount: links.clickCount, - createdAt: links.createdAt, - }) - .from(links) - .where(and(eq(links.userId, user.id), eq(links.isActive, true))) - .orderBy(desc(links.createdAt)) - .limit(50); - - return c.json({ user, links: userLinks }); + return c.json({ + user: { username, name: null, bio: null }, + links, + }); }); } diff --git a/apps/uload/apps/server/src/routes/stripe.ts b/apps/uload/apps/server/src/routes/stripe.ts index 161daf3c3..be6c90062 100644 --- a/apps/uload/apps/server/src/routes/stripe.ts +++ b/apps/uload/apps/server/src/routes/stripe.ts @@ -1,14 +1,72 @@ import { Hono } from 'hono'; +import Stripe from 'stripe'; import type { AuthUser } from '../middleware/jwt-auth'; +import type { Config } from '../config'; + +const PRICES = { + monthly: { lookup: 'uload_pro_monthly', amount: 499 }, + yearly: { lookup: 'uload_pro_yearly', amount: 3999 }, +} as const; + +export function createStripeRoutes(config: Config) { + const stripe = config.stripeSecretKey ? new Stripe(config.stripeSecretKey) : null; -export function createStripeRoutes() { return new Hono<{ Variables: { user: AuthUser } }>() .post('/checkout', async (c) => { - // TODO: Implement Stripe checkout session creation - return c.json({ error: 'Stripe not configured yet' }, 501); + if (!stripe) return c.json({ error: 'Stripe not configured' }, 501); + + const user = c.get('user'); + const { priceType } = await c.req.json<{ priceType: keyof typeof PRICES }>(); + const price = PRICES[priceType]; + if (!price) return c.json({ error: 'Invalid price type' }, 400); + + const session = await stripe.checkout.sessions.create({ + mode: 'subscription', + customer_email: user.email, + metadata: { userId: user.userId }, + line_items: [ + { + price_data: { + currency: 'eur', + unit_amount: price.amount, + recurring: { interval: priceType === 'yearly' ? 'year' : 'month' }, + product_data: { name: `uLoad Pro (${priceType})` }, + }, + quantity: 1, + }, + ], + success_url: `${config.baseUrl}/settings?checkout=success`, + cancel_url: `${config.baseUrl}/pricing`, + }); + + return c.json({ url: session.url }); }) .post('/webhook', async (c) => { - // TODO: Implement Stripe webhook handling + if (!stripe) return c.json({ error: 'Stripe not configured' }, 501); + + const body = await c.req.text(); + const sig = c.req.header('stripe-signature'); + if (!sig) return c.json({ error: 'Missing signature' }, 400); + + let event: Stripe.Event; + try { + event = stripe.webhooks.constructEvent(body, sig, config.stripeWebhookSecret); + } catch { + return c.json({ error: 'Invalid signature' }, 400); + } + + switch (event.type) { + case 'checkout.session.completed': { + // TODO: Update user subscription status in mana-user or sync_changes + // const _session = event.data.object as Stripe.Checkout.Session; + break; + } + case 'customer.subscription.deleted': { + // TODO: Reset user to free tier + break; + } + } + return c.json({ received: true }); }); } diff --git a/apps/uload/apps/server/src/services/analytics.ts b/apps/uload/apps/server/src/services/analytics.ts index 42942582b..462eff2f9 100644 --- a/apps/uload/apps/server/src/services/analytics.ts +++ b/apps/uload/apps/server/src/services/analytics.ts @@ -1,87 +1,82 @@ -import { eq, sql, and, gte, lte, desc } from 'drizzle-orm'; -import { clicks } from '@manacore/uload-database'; +import { sql } from 'drizzle-orm'; import type { Database } from '../db/connection'; +/** + * Analytics service that reads click data from mana-sync's sync_changes table. + * Clicks are stored with app_id='uload', table_name='clicks'. + */ export class AnalyticsService { constructor(private db: Database) {} - async getClicksByLink(linkId: string, from?: Date, to?: Date) { - const conditions = [eq(clicks.linkId, linkId)]; - if (from) conditions.push(gte(clicks.clickedAt, from)); - if (to) conditions.push(lte(clicks.clickedAt, to)); - - return this.db - .select() - .from(clicks) - .where(and(...conditions)) - .orderBy(desc(clicks.clickedAt)); - } - async getClickStats(linkId: string) { - const [stats] = await this.db - .select({ - totalClicks: sql`count(*)`, - uniqueVisitors: sql`count(distinct ${clicks.ipHash})`, - browsers: sql>`json_object_agg( - coalesce(${clicks.browser}, 'unknown'), - 1 - )`, - }) - .from(clicks) - .where(eq(clicks.linkId, linkId)); - - return stats; + const result = await this.db.execute(sql` + SELECT + count(*)::int as "totalClicks", + count(DISTINCT data->>'ipHash')::int as "uniqueVisitors" + FROM sync_changes + WHERE app_id = 'uload' AND table_name = 'clicks' + AND data->>'linkId' = ${linkId} + AND op = 'insert' + `); + const rows = result as unknown as { totalClicks: number; uniqueVisitors: number }[]; + return rows[0] ?? { totalClicks: 0, uniqueVisitors: 0 }; } async getClicksOverTime(linkId: string, days = 30) { - const since = new Date(); - since.setDate(since.getDate() - days); - - return this.db - .select({ - date: sql`date_trunc('day', ${clicks.clickedAt})::date`, - count: sql`count(*)`, - }) - .from(clicks) - .where(and(eq(clicks.linkId, linkId), gte(clicks.clickedAt, since))) - .groupBy(sql`date_trunc('day', ${clicks.clickedAt})`) - .orderBy(sql`date_trunc('day', ${clicks.clickedAt})`); + return this.db.execute(sql` + SELECT + date_trunc('day', created_at)::date::text as date, + count(*)::int as count + FROM sync_changes + WHERE app_id = 'uload' AND table_name = 'clicks' + AND data->>'linkId' = ${linkId} + AND op = 'insert' + AND created_at >= now() - make_interval(days => ${days}) + GROUP BY date_trunc('day', created_at) + ORDER BY date_trunc('day', created_at) + `) as unknown as { date: string; count: number }[]; } async getTopReferrers(linkId: string, limit = 10) { - return this.db - .select({ - referer: clicks.referer, - count: sql`count(*)`, - }) - .from(clicks) - .where(eq(clicks.linkId, linkId)) - .groupBy(clicks.referer) - .orderBy(desc(sql`count(*)`)) - .limit(limit); + return this.db.execute(sql` + SELECT + COALESCE(data->>'referer', 'Direct') as referer, + count(*)::int as count + FROM sync_changes + WHERE app_id = 'uload' AND table_name = 'clicks' + AND data->>'linkId' = ${linkId} + AND op = 'insert' + GROUP BY data->>'referer' + ORDER BY count(*) DESC + LIMIT ${limit} + `) as unknown as { referer: string; count: number }[]; } async getDeviceBreakdown(linkId: string) { - return this.db - .select({ - deviceType: clicks.deviceType, - count: sql`count(*)`, - }) - .from(clicks) - .where(eq(clicks.linkId, linkId)) - .groupBy(clicks.deviceType) - .orderBy(desc(sql`count(*)`)); + return this.db.execute(sql` + SELECT + COALESCE(data->>'deviceType', 'Unknown') as "deviceType", + count(*)::int as count + FROM sync_changes + WHERE app_id = 'uload' AND table_name = 'clicks' + AND data->>'linkId' = ${linkId} + AND op = 'insert' + GROUP BY data->>'deviceType' + ORDER BY count(*) DESC + `) as unknown as { deviceType: string; count: number }[]; } async getCountryBreakdown(linkId: string) { - return this.db - .select({ - country: clicks.country, - count: sql`count(*)`, - }) - .from(clicks) - .where(eq(clicks.linkId, linkId)) - .groupBy(clicks.country) - .orderBy(desc(sql`count(*)`)); + return this.db.execute(sql` + SELECT + COALESCE(data->>'country', 'Unknown') as country, + count(*)::int as count + FROM sync_changes + WHERE app_id = 'uload' AND table_name = 'clicks' + AND data->>'linkId' = ${linkId} + AND op = 'insert' + GROUP BY data->>'country' + ORDER BY count(*) DESC + `) as unknown as { country: string; count: number }[]; } } diff --git a/apps/uload/apps/server/src/services/redirect.ts b/apps/uload/apps/server/src/services/redirect.ts index abff401df..f204e2dbc 100644 --- a/apps/uload/apps/server/src/services/redirect.ts +++ b/apps/uload/apps/server/src/services/redirect.ts @@ -1,29 +1,49 @@ -import { eq, sql } from 'drizzle-orm'; -import { links, clicks } from '@manacore/uload-database'; +import { sql } from 'drizzle-orm'; import type { Database } from '../db/connection'; +interface ResolvedLink { + id: string; + originalUrl: string; + isActive: boolean; + password: string | null; + maxClicks: number | null; + clickCount: number; + expiresAt: string | null; +} + +/** + * Reads link data from mana-sync's sync_changes table. + * Data is stored as JSONB in the `data` column with app_id='uload' and table_name='links'. + * We get the latest version of each record by using DISTINCT ON. + */ export class RedirectService { constructor(private db: Database) {} - async resolve(shortCode: string) { - const [link] = await this.db - .select({ - id: links.id, - originalUrl: links.originalUrl, - isActive: links.isActive, - password: links.password, - maxClicks: links.maxClicks, - clickCount: links.clickCount, - expiresAt: links.expiresAt, - }) - .from(links) - .where(eq(links.shortCode, shortCode)) - .limit(1); + async resolve(shortCode: string): Promise { + const result = await this.db.execute(sql` + SELECT DISTINCT ON (record_id) + record_id as id, + data->>'originalUrl' as "originalUrl", + COALESCE((data->>'isActive')::boolean, true) as "isActive", + data->>'password' as password, + (data->>'maxClicks')::int as "maxClicks", + COALESCE((data->>'clickCount')::int, 0) as "clickCount", + data->>'expiresAt' as "expiresAt" + FROM sync_changes + WHERE app_id = 'uload' + AND table_name = 'links' + AND data->>'shortCode' = ${shortCode} + AND op != 'delete' + ORDER BY record_id, created_at DESC + LIMIT 1 + `); + const rows = result as unknown as ResolvedLink[]; + const link = rows[0]; if (!link) return null; if (!link.isActive) return null; if (link.expiresAt && new Date(link.expiresAt) < new Date()) return null; - if (link.maxClicks && link.clickCount && link.clickCount >= link.maxClicks) return null; + if (link.maxClicks && link.clickCount >= link.maxClicks) return null; return link; } @@ -40,21 +60,24 @@ export class RedirectService { country?: string; } ) { - await Promise.all([ - this.db.insert(clicks).values({ - linkId, - ipHash: meta.ipHash, - userAgent: meta.userAgent, - referer: meta.referer, - browser: meta.browser, - deviceType: meta.deviceType, - os: meta.os, - country: meta.country, - }), - this.db - .update(links) - .set({ clickCount: sql`${links.clickCount} + 1` }) - .where(eq(links.id, linkId)), - ]); + const clickId = crypto.randomUUID(); + const clickData = JSON.stringify({ + id: clickId, + linkId, + ipHash: meta.ipHash || null, + userAgent: meta.userAgent || null, + referer: meta.referer || null, + browser: meta.browser || null, + deviceType: meta.deviceType || null, + os: meta.os || null, + country: meta.country || null, + clickedAt: new Date().toISOString(), + }); + + // Insert click as a sync_changes record so it's visible to clients + await this.db.execute(sql` + INSERT INTO sync_changes (app_id, table_name, record_id, user_id, op, data, client_id) + VALUES ('uload', 'clicks', ${clickId}, 'system', 'insert', ${clickData}::jsonb, 'uload-server') + `); } } diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index bb456854f..1178ef9e2 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -1421,7 +1421,7 @@ services: environment: NODE_ENV: production PORT: 3041 - DATABASE_URL: postgresql://manacore:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/uload + DATABASE_URL: postgresql://manacore:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/mana_sync MANA_CORE_AUTH_URL: http://mana-auth:3001 CORS_ORIGINS: http://uload-web:5029,https://uload.mana.how,https://ulo.ad ports: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f6f7ada7..5603898bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4919,9 +4919,6 @@ importers: apps/uload/apps/server: dependencies: - '@manacore/uload-database': - specifier: workspace:* - version: link:../../packages/uload-database drizzle-orm: specifier: ^0.44.7 version: 0.44.7(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(gel@2.2.0)(kysely@0.28.8)(postgres@3.4.7) @@ -4934,6 +4931,9 @@ importers: postgres: specifier: ^3.4.7 version: 3.4.7 + stripe: + specifier: ^18.4.0 + version: 18.5.0(@types/node@24.10.1) devDependencies: '@types/bun': specifier: ^1.2.0 @@ -20226,6 +20226,15 @@ packages: resolution: {integrity: sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==} engines: {node: '>=12.*'} + stripe@18.5.0: + resolution: {integrity: sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA==} + engines: {node: '>=12.*'} + peerDependencies: + '@types/node': '>=12.x.x' + peerDependenciesMeta: + '@types/node': + optional: true + strnum@1.1.2: resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} @@ -44870,6 +44879,12 @@ snapshots: '@types/node': 22.19.1 qs: 6.14.0 + stripe@18.5.0(@types/node@24.10.1): + dependencies: + qs: 6.14.0 + optionalDependencies: + '@types/node': 24.10.1 + strnum@1.1.2: {} strnum@2.1.1: {} diff --git a/services/mana-sync/CLAUDE.md b/services/mana-sync/CLAUDE.md index e43e1bd82..8fb0d2388 100644 --- a/services/mana-sync/CLAUDE.md +++ b/services/mana-sync/CLAUDE.md @@ -192,4 +192,4 @@ services/mana-sync/ ## Connected Apps (19) -Todo, Calendar, Clock, Contacts, Chat, Questions, Mukke, Context, Photos, ManaDeck, Picture, Presi, Storage, Zitare, SkillTree, CityCorners, NutriPhi, Planta, Inventar +Todo, Calendar, Clock, Contacts, Chat, Questions, Mukke, Context, Photos, ManaDeck, Picture, Presi, Storage, Zitare, SkillTree, CityCorners, NutriPhi, Planta, Inventar, uLoad, Taktik, Calc