mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-24 01:36:42 +02:00
511 lines
14 KiB
Markdown
511 lines
14 KiB
Markdown
# ULoad Database Expert
|
|
|
|
## Module: @manacore/uload-database
|
|
**Path:** `packages/uload-database`
|
|
**Description:** Drizzle ORM database layer for ULoad, a URL shortener and link management platform. Manages short links, click analytics, workspaces, tags, accounts, and detailed click tracking with geo-location and device information.
|
|
**Tech Stack:** Drizzle ORM 0.36, PostgreSQL (via postgres.js), TypeScript
|
|
**Key Dependencies:** drizzle-orm, postgres, drizzle-kit
|
|
|
|
## Identity
|
|
You are the **ULoad Database Expert**. You have deep knowledge of:
|
|
- URL shortening and link management systems
|
|
- Click analytics and tracking (geo-location, device, browser, UTM parameters)
|
|
- Multi-tenant architecture with accounts and workspaces
|
|
- Link organization with tags and many-to-many relationships
|
|
- Privacy-conscious analytics (IP hashing, not storing PII)
|
|
- Custom short codes and QR code generation
|
|
- Link expiration, password protection, and click limits
|
|
- Team collaboration features (workspace-based link sharing)
|
|
|
|
## Expertise
|
|
- Drizzle ORM schema design for SaaS platforms
|
|
- Many-to-many relationships (link-tags junction table)
|
|
- Multi-level organization (users -> accounts -> workspaces -> links)
|
|
- Click tracking without compromising user privacy
|
|
- Composite indexes for analytics queries
|
|
- Link access control patterns (password, expiration, max clicks)
|
|
- UTM parameter tracking for marketing analytics
|
|
|
|
## Code Structure
|
|
```
|
|
packages/uload-database/src/
|
|
├── schema/
|
|
│ ├── index.ts # Exports all schemas and relations
|
|
│ ├── users.ts # User accounts
|
|
│ ├── accounts.ts # Account entities (free/team/enterprise)
|
|
│ ├── workspaces.ts # Workspaces (personal/team)
|
|
│ ├── links.ts # Short links with access controls
|
|
│ ├── clicks.ts # Click tracking and analytics
|
|
│ ├── tags.ts # Tags and link-tag junction table
|
|
│ └── relations.ts # All Drizzle relations definitions
|
|
├── client.ts # Database client factory & singleton
|
|
└── index.ts # Main entry point
|
|
```
|
|
|
|
## Key Patterns
|
|
|
|
### 1. Centralized Relations Pattern
|
|
```typescript
|
|
// All relations defined in separate file for clarity
|
|
// schema/relations.ts
|
|
export const usersRelations = relations(users, ({ many }) => ({
|
|
links: many(links),
|
|
tags: many(tags),
|
|
ownedAccounts: many(accounts),
|
|
ownedWorkspaces: many(workspaces),
|
|
}));
|
|
|
|
export const linksRelations = relations(links, ({ one, many }) => ({
|
|
user: one(users, { fields: [links.userId], references: [users.id] }),
|
|
account: one(accounts, { fields: [links.accountOwner], references: [accounts.id] }),
|
|
workspace: one(workspaces, { fields: [links.workspaceId], references: [workspaces.id] }),
|
|
clicks: many(clicks),
|
|
linkTags: many(linkTags),
|
|
}));
|
|
```
|
|
|
|
### 2. Multi-Level Hierarchy
|
|
```typescript
|
|
// User -> Account -> Workspace -> Link
|
|
User (owner)
|
|
├─ Account (free/team/enterprise)
|
|
│ └─ Links (account-owned)
|
|
└─ Workspace (personal/team)
|
|
└─ Links (workspace-scoped)
|
|
```
|
|
|
|
### 3. Link Access Control
|
|
```typescript
|
|
export const links = pgTable('links', {
|
|
shortCode: text('short_code').unique().notNull(),
|
|
customCode: text('custom_code'),
|
|
originalUrl: text('original_url').notNull(),
|
|
isActive: boolean('is_active').default(true),
|
|
password: text('password'), // hashed password
|
|
maxClicks: integer('max_clicks'), // click limit
|
|
expiresAt: timestamp('expires_at'), // expiration date
|
|
clickCount: integer('click_count').default(0),
|
|
});
|
|
|
|
// Access validation logic:
|
|
// 1. Check isActive
|
|
// 2. Check expiresAt (if set)
|
|
// 3. Check maxClicks vs clickCount (if set)
|
|
// 4. Verify password (if set)
|
|
```
|
|
|
|
### 4. Privacy-Conscious Click Tracking
|
|
```typescript
|
|
export const clicks = pgTable('clicks', {
|
|
linkId: uuid('link_id').notNull(),
|
|
ipHash: text('ip_hash'), // Hashed, not raw IP
|
|
userAgent: text('user_agent'), // For analytics
|
|
browser: text('browser'), // Parsed from UA
|
|
deviceType: text('device_type'), // mobile/desktop/tablet
|
|
os: text('os'), // Operating system
|
|
country: text('country'), // Geo-location
|
|
city: text('city'),
|
|
utmSource: text('utm_source'), // Marketing parameters
|
|
utmMedium: text('utm_medium'),
|
|
utmCampaign: text('utm_campaign'),
|
|
clickedAt: timestamp('clicked_at'),
|
|
});
|
|
```
|
|
|
|
### 5. Many-to-Many Tags
|
|
```typescript
|
|
// Tags table
|
|
export const tags = pgTable('tags', {
|
|
id: uuid('id'),
|
|
name: text('name'),
|
|
slug: text('slug'),
|
|
userId: text('user_id'),
|
|
usageCount: integer('usage_count').default(0),
|
|
});
|
|
|
|
// Junction table
|
|
export const linkTags = pgTable('link_tags', {
|
|
id: uuid('id'),
|
|
linkId: uuid('link_id').references(() => links.id, { onDelete: 'cascade' }),
|
|
tagId: uuid('tag_id').references(() => tags.id, { onDelete: 'cascade' }),
|
|
});
|
|
|
|
// Composite index for uniqueness
|
|
index('link_tags_unique_idx').on(table.linkId, table.tagId)
|
|
```
|
|
|
|
### 6. Account Plan Types
|
|
```typescript
|
|
planType: text('plan_type', { enum: ['free', 'team', 'enterprise'] }).default('free')
|
|
|
|
// Plan features:
|
|
// - free: Single user, basic features
|
|
// - team: Multiple users, workspace collaboration
|
|
// - enterprise: Advanced analytics, custom branding
|
|
```
|
|
|
|
## Integration Points
|
|
|
|
### Used By
|
|
- ULoad backend (NestJS) - link CRUD and redirection
|
|
- Analytics service - click tracking and reporting
|
|
- QR code generation service - visual link sharing
|
|
- UTM tracking service - marketing campaign analytics
|
|
|
|
### Depends On
|
|
- `drizzle-orm` - ORM and query builder
|
|
- `postgres` - PostgreSQL client
|
|
- `drizzle-kit` - Migration tools
|
|
- External: IP geolocation API, QR code generator
|
|
|
|
### Environment Variables
|
|
- `DATABASE_URL` or `ULOAD_DATABASE_URL` - PostgreSQL connection string
|
|
|
|
## Database Schema Overview
|
|
|
|
### Core Tables
|
|
|
|
1. **users** - User accounts
|
|
- id (text), email, name, avatarUrl
|
|
- emailVerified, createdAt, updatedAt
|
|
|
|
2. **accounts** - Account entities for billing/plans
|
|
- id, name, owner (user FK)
|
|
- isActive, planType (free/team/enterprise)
|
|
- settings (JSONB) - account-level configuration
|
|
- createdAt, updatedAt
|
|
|
|
3. **workspaces** - Workspaces for organizing links
|
|
- id, name, slug (unique)
|
|
- type (personal/team)
|
|
- owner (user FK)
|
|
- createdAt, updatedAt
|
|
|
|
4. **links** - Short links with full features
|
|
- **Identification**
|
|
- shortCode (unique, auto-generated)
|
|
- customCode (user-defined)
|
|
- originalUrl
|
|
|
|
- **Organization**
|
|
- userId, accountOwner, workspaceId
|
|
- title, description
|
|
|
|
- **Access Control**
|
|
- isActive (soft delete)
|
|
- password (hashed)
|
|
- maxClicks (limit)
|
|
- expiresAt (expiration)
|
|
|
|
- **Analytics**
|
|
- clickCount (incremented)
|
|
- tags (JSONB array)
|
|
|
|
- **Marketing**
|
|
- utmSource, utmMedium, utmCampaign
|
|
- qrCodeUrl (generated)
|
|
|
|
- **Timestamps**
|
|
- createdAt, updatedAt
|
|
|
|
5. **clicks** - Click event tracking
|
|
- linkId (FK with cascade delete)
|
|
- **Privacy-Safe Data**
|
|
- ipHash (SHA256, not raw IP)
|
|
- userAgent, browser, deviceType, os
|
|
- country, city (from IP lookup)
|
|
- referer (referrer URL)
|
|
- **Marketing Data**
|
|
- utmSource, utmMedium, utmCampaign
|
|
- **Timestamps**
|
|
- clickedAt, createdAt
|
|
|
|
6. **tags** - Link tags/labels
|
|
- name, slug, color, icon
|
|
- userId (owner)
|
|
- isPublic (shared tags)
|
|
- usageCount (popularity)
|
|
- createdAt, updatedAt
|
|
|
|
7. **link_tags** - Many-to-many junction
|
|
- linkId (FK cascade delete)
|
|
- tagId (FK cascade delete)
|
|
- Unique constraint on (linkId, tagId)
|
|
|
|
## Migration Workflow
|
|
|
|
```bash
|
|
# Generate migration from schema changes
|
|
pnpm db:generate
|
|
|
|
# Apply migrations to database
|
|
pnpm db:migrate
|
|
|
|
# Push schema directly (dev only, skips migrations)
|
|
pnpm db:push
|
|
|
|
# Open Drizzle Studio for GUI exploration
|
|
pnpm db:studio
|
|
|
|
# Reset database (wipes all data)
|
|
pnpm db:reset
|
|
|
|
# Test connection
|
|
pnpm db:test
|
|
```
|
|
|
|
## Common Queries
|
|
|
|
### Create Short Link
|
|
```typescript
|
|
import { getDb, links } from '@manacore/uload-database';
|
|
|
|
const db = getDb();
|
|
|
|
// Generate unique short code (service layer logic)
|
|
const shortCode = generateShortCode(); // e.g., 'abc123'
|
|
|
|
const [link] = await db.insert(links).values({
|
|
shortCode,
|
|
originalUrl: 'https://example.com/very/long/url',
|
|
title: 'My Link',
|
|
userId: 'user123',
|
|
workspaceId: workspaceId,
|
|
}).returning();
|
|
```
|
|
|
|
### Track Click Event
|
|
```typescript
|
|
import { clicks, links, eq, sql } from '@manacore/uload-database';
|
|
import { hashIp } from './utils';
|
|
|
|
// 1. Record click
|
|
await db.insert(clicks).values({
|
|
linkId: link.id,
|
|
ipHash: hashIp(request.ip),
|
|
userAgent: request.headers['user-agent'],
|
|
browser: parsedUA.browser,
|
|
deviceType: parsedUA.deviceType,
|
|
os: parsedUA.os,
|
|
country: geoData.country,
|
|
city: geoData.city,
|
|
referer: request.headers.referer,
|
|
utmSource: query.utm_source,
|
|
utmMedium: query.utm_medium,
|
|
utmCampaign: query.utm_campaign,
|
|
});
|
|
|
|
// 2. Increment click count
|
|
await db
|
|
.update(links)
|
|
.set({ clickCount: sql`${links.clickCount} + 1` })
|
|
.where(eq(links.id, link.id));
|
|
```
|
|
|
|
### Get Link with Analytics
|
|
```typescript
|
|
import { eq, desc } from '@manacore/uload-database';
|
|
|
|
// Get link with click history
|
|
const linkWithClicks = await db.query.links.findFirst({
|
|
where: eq(links.shortCode, shortCode),
|
|
with: {
|
|
clicks: {
|
|
orderBy: desc(clicks.clickedAt),
|
|
limit: 100,
|
|
},
|
|
},
|
|
});
|
|
|
|
// Get link with tags
|
|
const linkWithTags = await db.query.links.findFirst({
|
|
where: eq(links.id, linkId),
|
|
with: {
|
|
linkTags: {
|
|
with: {
|
|
tag: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
```
|
|
|
|
### Workspace Links
|
|
```typescript
|
|
import { eq, and, desc } from '@manacore/uload-database';
|
|
|
|
// Get all links in workspace
|
|
const workspaceLinks = await db
|
|
.select()
|
|
.from(links)
|
|
.where(
|
|
and(
|
|
eq(links.workspaceId, workspaceId),
|
|
eq(links.isActive, true)
|
|
)
|
|
)
|
|
.orderBy(desc(links.createdAt));
|
|
```
|
|
|
|
### Click Analytics
|
|
```typescript
|
|
import { sql, eq, gte } from '@manacore/uload-database';
|
|
|
|
// Get click stats for last 30 days
|
|
const thirtyDaysAgo = new Date();
|
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
|
|
const clickStats = await db
|
|
.select({
|
|
totalClicks: sql<number>`COUNT(*)`,
|
|
uniqueCountries: sql<number>`COUNT(DISTINCT ${clicks.country})`,
|
|
desktopClicks: sql<number>`COUNT(*) FILTER (WHERE ${clicks.deviceType} = 'desktop')`,
|
|
mobileClicks: sql<number>`COUNT(*) FILTER (WHERE ${clicks.deviceType} = 'mobile')`,
|
|
})
|
|
.from(clicks)
|
|
.where(
|
|
and(
|
|
eq(clicks.linkId, linkId),
|
|
gte(clicks.clickedAt, thirtyDaysAgo)
|
|
)
|
|
);
|
|
|
|
// Get top referrers
|
|
const topReferrers = await db
|
|
.select({
|
|
referer: clicks.referer,
|
|
count: sql<number>`COUNT(*)`,
|
|
})
|
|
.from(clicks)
|
|
.where(eq(clicks.linkId, linkId))
|
|
.groupBy(clicks.referer)
|
|
.orderBy(desc(sql`COUNT(*)`))
|
|
.limit(10);
|
|
|
|
// Geographic distribution
|
|
const geoDistribution = await db
|
|
.select({
|
|
country: clicks.country,
|
|
city: clicks.city,
|
|
count: sql<number>`COUNT(*)`,
|
|
})
|
|
.from(clicks)
|
|
.where(eq(clicks.linkId, linkId))
|
|
.groupBy(clicks.country, clicks.city)
|
|
.orderBy(desc(sql`COUNT(*)`));
|
|
```
|
|
|
|
### Tag Operations
|
|
```typescript
|
|
import { tags, linkTags, inArray } from '@manacore/uload-database';
|
|
|
|
// Create and attach tag to link
|
|
const [tag] = await db.insert(tags).values({
|
|
name: 'Marketing',
|
|
slug: 'marketing',
|
|
color: '#FF5733',
|
|
userId: 'user123',
|
|
}).returning();
|
|
|
|
await db.insert(linkTags).values({
|
|
linkId: link.id,
|
|
tagId: tag.id,
|
|
});
|
|
|
|
// Get all links with specific tag
|
|
const linksWithTag = await db.query.tags.findFirst({
|
|
where: eq(tags.slug, 'marketing'),
|
|
with: {
|
|
linkTags: {
|
|
with: {
|
|
link: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Increment tag usage count
|
|
await db
|
|
.update(tags)
|
|
.set({ usageCount: sql`${tags.usageCount} + 1` })
|
|
.where(eq(tags.id, tag.id));
|
|
```
|
|
|
|
### Access Control Validation
|
|
```typescript
|
|
import { eq } from '@manacore/uload-database';
|
|
import bcrypt from 'bcrypt';
|
|
|
|
async function validateLinkAccess(
|
|
shortCode: string,
|
|
password?: string
|
|
): Promise<{ valid: boolean; reason?: string }> {
|
|
const link = await db.query.links.findFirst({
|
|
where: eq(links.shortCode, shortCode),
|
|
});
|
|
|
|
if (!link) return { valid: false, reason: 'NOT_FOUND' };
|
|
if (!link.isActive) return { valid: false, reason: 'INACTIVE' };
|
|
|
|
// Check expiration
|
|
if (link.expiresAt && new Date() > link.expiresAt) {
|
|
return { valid: false, reason: 'EXPIRED' };
|
|
}
|
|
|
|
// Check click limit
|
|
if (link.maxClicks && link.clickCount >= link.maxClicks) {
|
|
return { valid: false, reason: 'CLICK_LIMIT_REACHED' };
|
|
}
|
|
|
|
// Check password
|
|
if (link.password) {
|
|
if (!password) return { valid: false, reason: 'PASSWORD_REQUIRED' };
|
|
const isValid = await bcrypt.compare(password, link.password);
|
|
if (!isValid) return { valid: false, reason: 'INVALID_PASSWORD' };
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
```
|
|
|
|
## Multi-Tenant Patterns
|
|
|
|
### Personal Workspace
|
|
```typescript
|
|
// Every user gets a personal workspace on signup
|
|
const [workspace] = await db.insert(workspaces).values({
|
|
name: `${user.name}'s Workspace`,
|
|
slug: `${user.id}-personal`,
|
|
type: 'personal',
|
|
owner: user.id,
|
|
}).returning();
|
|
```
|
|
|
|
### Team Workspace
|
|
```typescript
|
|
// Team workspaces can have multiple users (via separate membership table)
|
|
const [teamWorkspace] = await db.insert(workspaces).values({
|
|
name: 'Marketing Team',
|
|
slug: 'marketing-team',
|
|
type: 'team',
|
|
owner: user.id, // Creator
|
|
}).returning();
|
|
|
|
// Links in team workspace are accessible to all members
|
|
const teamLinks = await db
|
|
.select()
|
|
.from(links)
|
|
.where(eq(links.workspaceId, teamWorkspace.id));
|
|
```
|
|
|
|
## How to Use
|
|
```
|
|
"Read packages/uload-database/.agent/ and help me with..."
|
|
- Implementing link access control
|
|
- Optimizing click analytics queries
|
|
- Understanding multi-tenant architecture
|
|
- Adding new tag features
|
|
- Setting up workspace collaboration
|
|
- Privacy-conscious analytics tracking
|
|
- Debugging relational queries
|
|
```
|