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 | | **inventar** | Inventory management | Web |
| **traces** | City exploration | Backend, Mobile | | **traces** | City exploration | Backend, Mobile |
| **taktik** | Time tracking | Web | | **taktik** | Time tracking | Web |
| **uload** | URL shortener & link management | Server, Web, Landing |
| **calc** | Calculator & converter | Web | | **calc** | Calculator & converter | Web |
| **playground** | LLM playground | 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:clock:full # Start clock with auth + auto DB setup
pnpm dev:todo:full # Start todo 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:picture:full # Start picture with auth + auto DB setup
pnpm dev:uload:full # Start uload with auth + auto DB setup
``` ```
These commands automatically: These commands automatically:
@ -579,6 +581,7 @@ Logged in: App → IndexedDB → UI → SyncEngine → mana-sync (Go) → Postg
| SkilltTree | skills, activities, achievements | Done | | SkilltTree | skills, activities, achievements | Done |
| CityCorners | locations, favorites | Done | | CityCorners | locations, favorites | Done |
| Taktik | clients, projects, timeEntries, tags, templates, settings | Done | | Taktik | clients, projects, timeEntries, tags, templates, settings | Done |
| uLoad | links, tags, folders, linkTags | Done |
| Calc | calculations, savedFormulas | Done | | Calc | calculations, savedFormulas | Done |
**Not migrated (no CRUD data model):** ManaCore (hub), Matrix (protocol client), Playground (stateless) **Not migrated (no CRUD data model):** ManaCore (hub), Matrix (protocol client), Playground (stateless)

View file

@ -9,9 +9,9 @@
"type-check": "tsc --noEmit" "type-check": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@manacore/uload-database": "workspace:*",
"drizzle-orm": "^0.44.7", "drizzle-orm": "^0.44.7",
"hono": "^4.7.0", "hono": "^4.7.0",
"stripe": "^18.4.0",
"jose": "^6.1.2", "jose": "^6.1.2",
"postgres": "^3.4.7" "postgres": "^3.4.7"
}, },

View file

@ -3,6 +3,9 @@ export interface Config {
databaseUrl: string; databaseUrl: string;
manaAuthUrl: string; manaAuthUrl: string;
cors: { origins: string[] }; cors: { origins: string[] };
stripeSecretKey: string;
stripeWebhookSecret: string;
baseUrl: string;
} }
export function loadConfig(): Config { export function loadConfig(): Config {
@ -16,11 +19,14 @@ export function loadConfig(): Config {
port: parseInt(process.env.PORT || '3070', 10), port: parseInt(process.env.PORT || '3070', 10),
databaseUrl: requiredEnv( databaseUrl: requiredEnv(
'DATABASE_URL', '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'), manaAuthUrl: requiredEnv('MANA_CORE_AUTH_URL', 'http://localhost:3001'),
cors: { cors: {
origins: (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','), 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 { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres'; 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) { export function getDb(databaseUrl: string) {
if (!db) { if (!db) {
const client = postgres(databaseUrl, { max: 10 }); const client = postgres(databaseUrl, { max: 10 });
db = drizzle(client, { schema }); db = drizzle(client);
} }
return db; return db;
} }

View file

@ -31,10 +31,16 @@ app.route('/health', healthRoutes);
app.route('/r', createRedirectRoutes(redirectService)); app.route('/r', createRedirectRoutes(redirectService));
app.route('/public', createPublicRoutes(db)); 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.use('/api/v1/*', jwtAuth(config.manaAuthUrl));
app.route('/api/v1/analytics', createAnalyticsRoutes(analyticsService)); 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()); app.route('/api/v1/email', createEmailRoutes());
console.log(`uload-server starting on port ${config.port}...`); console.log(`uload-server starting on port ${config.port}...`);

View file

@ -1,35 +1,44 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { eq, and, desc } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { links, users } from '@manacore/uload-database';
import type { Database } from '../db/connection'; import type { Database } from '../db/connection';
export function createPublicRoutes(db: Database) { export function createPublicRoutes(db: Database) {
return new Hono().get('/u/:username', async (c) => { return new Hono().get('/u/:username', async (c) => {
const username = c.req.param('username'); const username = c.req.param('username');
const [user] = await db // Query links for a user from sync_changes
.select({ id: users.id, username: users.username, name: users.name, bio: users.bio }) // Note: In mana-sync, user_id is the auth user ID, not username.
.from(users) // For public profiles, we'd need a user lookup. For now, treat username as user_id.
.where(and(eq(users.username, username), eq(users.publicProfile, true))) const result = await db.execute(sql`
.limit(1); 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) { const links = result as unknown as {
return c.json({ error: 'User not found' }, 404); id: string;
} shortCode: string;
title: string | null;
description: string | null;
clickCount: number;
createdAt: string;
}[];
const userLinks = await db return c.json({
.select({ user: { username, name: null, bio: null },
shortCode: links.shortCode, links,
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 });
}); });
} }

View file

@ -1,14 +1,72 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import Stripe from 'stripe';
import type { AuthUser } from '../middleware/jwt-auth'; 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 } }>() return new Hono<{ Variables: { user: AuthUser } }>()
.post('/checkout', async (c) => { .post('/checkout', async (c) => {
// TODO: Implement Stripe checkout session creation if (!stripe) return c.json({ error: 'Stripe not configured' }, 501);
return c.json({ error: 'Stripe not configured yet' }, 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) => { .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 }); return c.json({ received: true });
}); });
} }

View file

@ -1,87 +1,82 @@
import { eq, sql, and, gte, lte, desc } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { clicks } from '@manacore/uload-database';
import type { Database } from '../db/connection'; 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 { export class AnalyticsService {
constructor(private db: Database) {} 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) { async getClickStats(linkId: string) {
const [stats] = await this.db const result = await this.db.execute(sql`
.select({ SELECT
totalClicks: sql<number>`count(*)`, count(*)::int as "totalClicks",
uniqueVisitors: sql<number>`count(distinct ${clicks.ipHash})`, count(DISTINCT data->>'ipHash')::int as "uniqueVisitors"
browsers: sql<Record<string, number>>`json_object_agg( FROM sync_changes
coalesce(${clicks.browser}, 'unknown'), WHERE app_id = 'uload' AND table_name = 'clicks'
1 AND data->>'linkId' = ${linkId}
)`, AND op = 'insert'
}) `);
.from(clicks) const rows = result as unknown as { totalClicks: number; uniqueVisitors: number }[];
.where(eq(clicks.linkId, linkId)); return rows[0] ?? { totalClicks: 0, uniqueVisitors: 0 };
return stats;
} }
async getClicksOverTime(linkId: string, days = 30) { async getClicksOverTime(linkId: string, days = 30) {
const since = new Date(); return this.db.execute(sql`
since.setDate(since.getDate() - days); SELECT
date_trunc('day', created_at)::date::text as date,
return this.db count(*)::int as count
.select({ FROM sync_changes
date: sql<string>`date_trunc('day', ${clicks.clickedAt})::date`, WHERE app_id = 'uload' AND table_name = 'clicks'
count: sql<number>`count(*)`, AND data->>'linkId' = ${linkId}
}) AND op = 'insert'
.from(clicks) AND created_at >= now() - make_interval(days => ${days})
.where(and(eq(clicks.linkId, linkId), gte(clicks.clickedAt, since))) GROUP BY date_trunc('day', created_at)
.groupBy(sql`date_trunc('day', ${clicks.clickedAt})`) ORDER BY date_trunc('day', created_at)
.orderBy(sql`date_trunc('day', ${clicks.clickedAt})`); `) as unknown as { date: string; count: number }[];
} }
async getTopReferrers(linkId: string, limit = 10) { async getTopReferrers(linkId: string, limit = 10) {
return this.db return this.db.execute(sql`
.select({ SELECT
referer: clicks.referer, COALESCE(data->>'referer', 'Direct') as referer,
count: sql<number>`count(*)`, count(*)::int as count
}) FROM sync_changes
.from(clicks) WHERE app_id = 'uload' AND table_name = 'clicks'
.where(eq(clicks.linkId, linkId)) AND data->>'linkId' = ${linkId}
.groupBy(clicks.referer) AND op = 'insert'
.orderBy(desc(sql`count(*)`)) GROUP BY data->>'referer'
.limit(limit); ORDER BY count(*) DESC
LIMIT ${limit}
`) as unknown as { referer: string; count: number }[];
} }
async getDeviceBreakdown(linkId: string) { async getDeviceBreakdown(linkId: string) {
return this.db return this.db.execute(sql`
.select({ SELECT
deviceType: clicks.deviceType, COALESCE(data->>'deviceType', 'Unknown') as "deviceType",
count: sql<number>`count(*)`, count(*)::int as count
}) FROM sync_changes
.from(clicks) WHERE app_id = 'uload' AND table_name = 'clicks'
.where(eq(clicks.linkId, linkId)) AND data->>'linkId' = ${linkId}
.groupBy(clicks.deviceType) AND op = 'insert'
.orderBy(desc(sql`count(*)`)); GROUP BY data->>'deviceType'
ORDER BY count(*) DESC
`) as unknown as { deviceType: string; count: number }[];
} }
async getCountryBreakdown(linkId: string) { async getCountryBreakdown(linkId: string) {
return this.db return this.db.execute(sql`
.select({ SELECT
country: clicks.country, COALESCE(data->>'country', 'Unknown') as country,
count: sql<number>`count(*)`, count(*)::int as count
}) FROM sync_changes
.from(clicks) WHERE app_id = 'uload' AND table_name = 'clicks'
.where(eq(clicks.linkId, linkId)) AND data->>'linkId' = ${linkId}
.groupBy(clicks.country) AND op = 'insert'
.orderBy(desc(sql`count(*)`)); 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 { sql } from 'drizzle-orm';
import { links, clicks } from '@manacore/uload-database';
import type { Database } from '../db/connection'; 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 { export class RedirectService {
constructor(private db: Database) {} constructor(private db: Database) {}
async resolve(shortCode: string) { async resolve(shortCode: string): Promise<ResolvedLink | null> {
const [link] = await this.db const result = await this.db.execute(sql`
.select({ SELECT DISTINCT ON (record_id)
id: links.id, record_id as id,
originalUrl: links.originalUrl, data->>'originalUrl' as "originalUrl",
isActive: links.isActive, COALESCE((data->>'isActive')::boolean, true) as "isActive",
password: links.password, data->>'password' as password,
maxClicks: links.maxClicks, (data->>'maxClicks')::int as "maxClicks",
clickCount: links.clickCount, COALESCE((data->>'clickCount')::int, 0) as "clickCount",
expiresAt: links.expiresAt, data->>'expiresAt' as "expiresAt"
}) FROM sync_changes
.from(links) WHERE app_id = 'uload'
.where(eq(links.shortCode, shortCode)) AND table_name = 'links'
.limit(1); 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) return null;
if (!link.isActive) return null; if (!link.isActive) return null;
if (link.expiresAt && new Date(link.expiresAt) < new Date()) 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; return link;
} }
@ -40,21 +60,24 @@ export class RedirectService {
country?: string; country?: string;
} }
) { ) {
await Promise.all([ const clickId = crypto.randomUUID();
this.db.insert(clicks).values({ const clickData = JSON.stringify({
linkId, id: clickId,
ipHash: meta.ipHash, linkId,
userAgent: meta.userAgent, ipHash: meta.ipHash || null,
referer: meta.referer, userAgent: meta.userAgent || null,
browser: meta.browser, referer: meta.referer || null,
deviceType: meta.deviceType, browser: meta.browser || null,
os: meta.os, deviceType: meta.deviceType || null,
country: meta.country, os: meta.os || null,
}), country: meta.country || null,
this.db clickedAt: new Date().toISOString(),
.update(links) });
.set({ clickCount: sql`${links.clickCount} + 1` })
.where(eq(links.id, linkId)), // 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: environment:
NODE_ENV: production NODE_ENV: production
PORT: 3041 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 MANA_CORE_AUTH_URL: http://mana-auth:3001
CORS_ORIGINS: http://uload-web:5029,https://uload.mana.how,https://ulo.ad CORS_ORIGINS: http://uload-web:5029,https://uload.mana.how,https://ulo.ad
ports: ports:

21
pnpm-lock.yaml generated
View file

@ -4919,9 +4919,6 @@ importers:
apps/uload/apps/server: apps/uload/apps/server:
dependencies: dependencies:
'@manacore/uload-database':
specifier: workspace:*
version: link:../../packages/uload-database
drizzle-orm: drizzle-orm:
specifier: ^0.44.7 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) 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: postgres:
specifier: ^3.4.7 specifier: ^3.4.7
version: 3.4.7 version: 3.4.7
stripe:
specifier: ^18.4.0
version: 18.5.0(@types/node@24.10.1)
devDependencies: devDependencies:
'@types/bun': '@types/bun':
specifier: ^1.2.0 specifier: ^1.2.0
@ -20226,6 +20226,15 @@ packages:
resolution: {integrity: sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==} resolution: {integrity: sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==}
engines: {node: '>=12.*'} 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: strnum@1.1.2:
resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==}
@ -44870,6 +44879,12 @@ snapshots:
'@types/node': 22.19.1 '@types/node': 22.19.1
qs: 6.14.0 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@1.1.2: {}
strnum@2.1.1: {} strnum@2.1.1: {}

View file

@ -192,4 +192,4 @@ services/mana-sync/
## Connected Apps (19) ## 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