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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-29 19:02:11 +02:00
parent 9e82e40e16
commit d02428fca1
12 changed files with 254 additions and 140 deletions

View file

@ -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)

View file

@ -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"
},

View file

@ -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',
};
}

View file

@ -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<typeof drizzle<typeof schema>> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getDb(databaseUrl: string) {
if (!db) {
const client = postgres(databaseUrl, { max: 10 });
db = drizzle(client, { schema });
db = drizzle(client);
}
return db;
}

View file

@ -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}...`);

View file

@ -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,
});
});
}

View file

@ -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 });
});
}

View file

@ -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<number>`count(*)`,
uniqueVisitors: sql<number>`count(distinct ${clicks.ipHash})`,
browsers: sql<Record<string, number>>`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<string>`date_trunc('day', ${clicks.clickedAt})::date`,
count: sql<number>`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<number>`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<number>`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<number>`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 }[];
}
}

View file

@ -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<ResolvedLink | null> {
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')
`);
}
}

View file

@ -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:

21
pnpm-lock.yaml generated
View file

@ -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: {}

View file

@ -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