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

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