chore: archive inactive projects to apps-archived/

Move inactive projects out of active workspace:
- bauntown (community website)
- maerchenzauber (AI story generation)
- memoro (voice memo app)
- news (news aggregation)
- nutriphi (nutrition tracking)
- reader (reading app)
- uload (URL shortener)
- wisekeep (AI wisdom extraction)

Update CLAUDE.md documentation:
- Add presi to active projects
- Document archived projects section
- Update workspace configuration

Archived apps can be re-activated by moving back to apps/

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-29 07:03:59 +01:00
parent b97149ac12
commit 61d181fbc2
3148 changed files with 437 additions and 46640 deletions

View file

@ -0,0 +1,75 @@
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ClsModule } from 'nestjs-cls';
import { TerminusModule } from '@nestjs/terminus';
import { HttpModule } from '@nestjs/axios';
import { ManaCoreModule } from '@mana-core/nestjs-integration';
import { validationSchema } from './config/validation.schema';
import { DatabaseModule } from './database/database.module';
import { LinkRepository } from './database/repositories/link.repository';
import { ClickRepository } from './database/repositories/click.repository';
import { HealthController } from './controllers/health.controller';
import { RedirectController } from './controllers/redirect.controller';
import { LinksController } from './controllers/links.controller';
import { AnalyticsController } from './controllers/analytics.controller';
import { LinksService } from './services/links.service';
import { RedirectService } from './services/redirect.service';
import { AnalyticsService } from './services/analytics.service';
@Module({
imports: [
// Context-Local Storage for request-scoped data
ClsModule.forRoot({
global: true,
middleware: { mount: true, generateId: true },
}),
// Configuration
ConfigModule.forRoot({
isGlobal: true,
validationSchema,
validationOptions: {
allowUnknown: true,
abortEarly: false,
},
ignoreEnvFile: process.env.NODE_ENV === 'production',
}),
// Mana Core Authentication
ManaCoreModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
manaServiceUrl: configService.get<string>('MANA_SERVICE_URL')!,
appId: configService.get<string>('APP_ID')!,
serviceKey: configService.get<string>('MANA_SERVICE_KEY', ''),
debug: configService.get('NODE_ENV') === 'development',
}),
inject: [ConfigService],
}) as any,
// Health checks
TerminusModule,
HttpModule,
// Database
DatabaseModule,
],
controllers: [HealthController, RedirectController, LinksController, AnalyticsController],
providers: [
// Repositories
LinkRepository,
ClickRepository,
// Services
LinksService,
RedirectService,
AnalyticsService,
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
// Add custom middleware here if needed
}
}

View file

@ -0,0 +1,26 @@
import * as Joi from 'joi';
export const validationSchema = Joi.object({
// Server
NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
PORT: Joi.number().default(3003),
// Database
DATABASE_URL: Joi.string().uri().required(),
// Redis
REDIS_HOST: Joi.string().default('localhost'),
REDIS_PORT: Joi.number().default(6379),
REDIS_PASSWORD: Joi.string().allow('').optional(),
// Mana Core Auth
MANA_SERVICE_URL: Joi.string().uri().required(),
APP_ID: Joi.string().uuid().required(),
MANA_SERVICE_KEY: Joi.string().allow('').optional(),
// Frontend
FRONTEND_URL: Joi.string().uri().optional(),
// Short URL
SHORT_URL_BASE: Joi.string().uri().default('https://ulo.ad'),
});

View file

@ -0,0 +1,95 @@
import {
Controller,
Get,
Param,
Query,
UseGuards,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { AuthGuard, CurrentUser } from '@mana-core/nestjs-integration';
import { AnalyticsService } from '../services/analytics.service';
import { LinksService } from '../services/links.service';
@Controller('api/analytics')
@UseGuards(AuthGuard)
export class AnalyticsController {
constructor(
private readonly analyticsService: AnalyticsService,
private readonly linksService: LinksService
) {}
@Get('links/:linkId')
async getLinkAnalytics(
@CurrentUser() user: any,
@Param('linkId') linkId: string,
@Query('from') fromDate?: string,
@Query('to') toDate?: string
) {
const userId = user.sub;
// Verify user owns the link
const link = await this.linksService.getLinkById(linkId, userId);
if (!link) {
throw new NotFoundException('Link not found');
}
const stats = await this.analyticsService.getStats(
linkId,
fromDate ? new Date(fromDate) : undefined,
toDate ? new Date(toDate) : undefined
);
return {
success: true,
data: {
linkId,
shortCode: link.shortCode,
stats,
},
};
}
@Get('links/:linkId/clicks')
async getLinkClicks(
@CurrentUser() user: any,
@Param('linkId') linkId: string,
@Query('limit') limit: number = 100
) {
const userId = user.sub;
// Verify user owns the link
const link = await this.linksService.getLinkById(linkId, userId);
if (!link) {
throw new NotFoundException('Link not found');
}
const { clicks, total } = await this.analyticsService.getRecentClicks(linkId, limit);
return {
success: true,
data: {
linkId,
clicks: clicks.map((click) => ({
...click,
ipHash: undefined, // Don't expose IP hash
})),
total,
},
};
}
@Get('overview')
async getOverview(@CurrentUser() user: any) {
const userId = user.sub;
const totalLinks = await this.linksService.getLinkCount(userId);
return {
success: true,
data: {
totalLinks,
// Add more overview stats as needed
},
};
}
}

View file

@ -0,0 +1,31 @@
import { Controller, Get } from '@nestjs/common';
import { HealthCheckService, HealthCheck, HealthCheckResult } from '@nestjs/terminus';
@Controller('health')
export class HealthController {
constructor(private health: HealthCheckService) {}
@Get()
@HealthCheck()
check(): Promise<HealthCheckResult> {
return this.health.check([]);
}
@Get('ready')
ready() {
return {
status: 'ready',
timestamp: new Date().toISOString(),
};
}
@Get('live')
live() {
return {
status: 'live',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: process.env.NODE_ENV || 'development',
};
}
}

View file

@ -0,0 +1,127 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
NotFoundException,
} from '@nestjs/common';
import { AuthGuard, CurrentUser } from '@mana-core/nestjs-integration';
import { LinksService, type CreateLinkDto, type UpdateLinkDto } from '../services/links.service';
@Controller('api/links')
@UseGuards(AuthGuard)
export class LinksController {
constructor(private readonly linksService: LinksService) {}
@Get()
async getLinks(
@CurrentUser() user: any,
@Query('page') page: number = 1,
@Query('limit') limit: number = 20,
@Query('search') search?: string,
@Query('isActive') isActive?: boolean
) {
const userId = user.sub;
const { items, total } = await this.linksService.getLinks(userId, {
page,
limit,
search,
isActive,
});
return {
success: true,
data: {
links: items.map((link) => ({
...link,
shortUrl: this.linksService.getShortUrl(link.shortCode),
hasPassword: !!link.password,
password: undefined, // Never send password to client
})),
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasMore: page * limit < total,
},
},
};
}
@Get(':id')
async getLink(@CurrentUser() user: any, @Param('id') id: string) {
const userId = user.sub;
const link = await this.linksService.getLinkById(id, userId);
if (!link) {
throw new NotFoundException('Link not found');
}
return {
success: true,
data: {
...link,
shortUrl: this.linksService.getShortUrl(link.shortCode),
hasPassword: !!link.password,
password: undefined,
},
};
}
@Post()
async createLink(@CurrentUser() user: any, @Body() dto: CreateLinkDto) {
const userId = user.sub;
const link = await this.linksService.createLink(userId, dto);
return {
success: true,
data: {
...link,
shortUrl: this.linksService.getShortUrl(link.shortCode),
hasPassword: !!link.password,
password: undefined,
},
};
}
@Patch(':id')
async updateLink(@CurrentUser() user: any, @Param('id') id: string, @Body() dto: UpdateLinkDto) {
const userId = user.sub;
const link = await this.linksService.updateLink(id, userId, dto);
if (!link) {
throw new NotFoundException('Link not found');
}
return {
success: true,
data: {
...link,
shortUrl: this.linksService.getShortUrl(link.shortCode),
hasPassword: !!link.password,
password: undefined,
},
};
}
@Delete(':id')
async deleteLink(@CurrentUser() user: any, @Param('id') id: string) {
const userId = user.sub;
const deleted = await this.linksService.deleteLink(id, userId);
if (!deleted) {
throw new NotFoundException('Link not found');
}
return {
success: true,
message: 'Link deleted successfully',
};
}
}

View file

@ -0,0 +1,103 @@
import { Controller, Get, Post, Param, Body, Req, Res, HttpStatus, Query } from '@nestjs/common';
import { Response, Request } from 'express';
import { RedirectService } from '../services/redirect.service';
import { AnalyticsService } from '../services/analytics.service';
@Controller()
export class RedirectController {
constructor(
private readonly redirectService: RedirectService,
private readonly analyticsService: AnalyticsService
) {}
@Get(':code')
async redirect(
@Param('code') code: string,
@Query('utm_source') utmSource: string,
@Query('utm_medium') utmMedium: string,
@Query('utm_campaign') utmCampaign: string,
@Req() request: Request,
@Res() response: Response
) {
// Skip for API and health routes
if (code === 'v1' || code === 'health') {
return response.status(HttpStatus.NOT_FOUND).json({
success: false,
error: 'not_found',
});
}
const result = await this.redirectService.getRedirect(code);
if (!result.success) {
switch (result.error) {
case 'not_found':
return response.status(HttpStatus.NOT_FOUND).json({
success: false,
error: 'Link not found',
});
case 'expired':
return response.status(HttpStatus.GONE).json({
success: false,
error: 'This link has expired',
});
case 'inactive':
return response.status(HttpStatus.GONE).json({
success: false,
error: 'This link is no longer active',
});
case 'max_clicks':
return response.status(HttpStatus.GONE).json({
success: false,
error: 'This link has reached its maximum clicks',
});
case 'password_required':
return response.status(HttpStatus.OK).json({
success: false,
passwordRequired: true,
linkId: result.linkId,
});
}
}
// Record click asynchronously (don't wait)
this.analyticsService
.recordClick(result.linkId!, {
userAgent: request.headers['user-agent'] || '',
referer: request.headers['referer'] || '',
ip: request.ip,
utmSource,
utmMedium,
utmCampaign,
})
.catch((err) => console.error('Failed to record click:', err));
// Perform redirect
return response.redirect(302, result.targetUrl!);
}
@Post(':code/unlock')
async unlockLink(
@Param('code') code: string,
@Body('password') password: string,
@Res() response: Response
) {
const result = await this.redirectService.verifyPassword(code, password);
if (!result.success) {
return response.status(HttpStatus.UNAUTHORIZED).json({
success: false,
error: 'Invalid password',
});
}
return response.json({
success: true,
targetUrl: result.targetUrl,
});
}
}

View file

@ -0,0 +1,29 @@
import { Module, Global, OnModuleDestroy, Logger } from '@nestjs/common';
import { getDb, closeDb, type Database } from '@manacore/uload-database';
export const DATABASE_TOKEN = 'DATABASE';
@Global()
@Module({
providers: [
{
provide: DATABASE_TOKEN,
useFactory: () => {
const logger = new Logger('DatabaseModule');
logger.log('Initializing database connection');
return getDb();
},
},
],
exports: [DATABASE_TOKEN],
})
export class DatabaseModule implements OnModuleDestroy {
private readonly logger = new Logger(DatabaseModule.name);
async onModuleDestroy() {
this.logger.log('Closing database connection');
await closeDb();
}
}
export type { Database };

View file

@ -0,0 +1,158 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { DATABASE_TOKEN, type Database } from '../database.module';
import {
clicks,
type Click,
type NewClick,
eq,
desc,
sql,
and,
gte,
lte,
} from '@manacore/uload-database';
export interface ClickStats {
totalClicks: number;
uniqueVisitors: number;
topCountries: { country: string; count: number }[];
topBrowsers: { browser: string; count: number }[];
topDevices: { deviceType: string; count: number }[];
clicksByDay: { date: string; count: number }[];
}
@Injectable()
export class ClickRepository {
private readonly logger = new Logger(ClickRepository.name);
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
async create(data: NewClick): Promise<Click> {
const result = await this.db.insert(clicks).values(data).returning();
return result[0];
}
async findByLinkId(
linkId: string,
options: { limit?: number; offset?: number } = {}
): Promise<Click[]> {
const { limit = 100, offset = 0 } = options;
return this.db
.select()
.from(clicks)
.where(eq(clicks.linkId, linkId))
.orderBy(desc(clicks.clickedAt))
.limit(limit)
.offset(offset);
}
async countByLinkId(linkId: string): Promise<number> {
const result = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(clicks)
.where(eq(clicks.linkId, linkId));
return result[0]?.count || 0;
}
async getStats(linkId: string, fromDate?: Date, toDate?: Date): Promise<ClickStats> {
const conditions = [eq(clicks.linkId, linkId)];
if (fromDate) {
conditions.push(gte(clicks.clickedAt, fromDate));
}
if (toDate) {
conditions.push(lte(clicks.clickedAt, toDate));
}
const whereClause = and(...conditions);
// Total clicks
const totalResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(clicks)
.where(whereClause);
// Unique visitors (by IP hash)
const uniqueResult = await this.db
.select({ count: sql<number>`count(distinct ${clicks.ipHash})::int` })
.from(clicks)
.where(whereClause);
// Top countries
const countriesResult = await this.db
.select({
country: clicks.country,
count: sql<number>`count(*)::int`,
})
.from(clicks)
.where(whereClause)
.groupBy(clicks.country)
.orderBy(sql`count(*) desc`)
.limit(10);
// Top browsers
const browsersResult = await this.db
.select({
browser: clicks.browser,
count: sql<number>`count(*)::int`,
})
.from(clicks)
.where(whereClause)
.groupBy(clicks.browser)
.orderBy(sql`count(*) desc`)
.limit(10);
// Top devices
const devicesResult = await this.db
.select({
deviceType: clicks.deviceType,
count: sql<number>`count(*)::int`,
})
.from(clicks)
.where(whereClause)
.groupBy(clicks.deviceType)
.orderBy(sql`count(*) desc`)
.limit(10);
// Clicks by day (last 30 days)
const clicksByDayResult = await this.db
.select({
date: sql<string>`date_trunc('day', ${clicks.clickedAt})::date::text`,
count: sql<number>`count(*)::int`,
})
.from(clicks)
.where(whereClause)
.groupBy(sql`date_trunc('day', ${clicks.clickedAt})`)
.orderBy(sql`date_trunc('day', ${clicks.clickedAt})`)
.limit(30);
return {
totalClicks: totalResult[0]?.count || 0,
uniqueVisitors: uniqueResult[0]?.count || 0,
topCountries: countriesResult.map((r) => ({
country: r.country || 'Unknown',
count: r.count,
})),
topBrowsers: browsersResult.map((r) => ({
browser: r.browser || 'Unknown',
count: r.count,
})),
topDevices: devicesResult.map((r) => ({
deviceType: r.deviceType || 'Unknown',
count: r.count,
})),
clicksByDay: clicksByDayResult.map((r) => ({
date: r.date,
count: r.count,
})),
};
}
async deleteByLinkId(linkId: string): Promise<number> {
const result = await this.db
.delete(clicks)
.where(eq(clicks.linkId, linkId))
.returning({ id: clicks.id });
return result.length;
}
}

View file

@ -0,0 +1,2 @@
export { LinkRepository, type ListLinksOptions } from './link.repository';
export { ClickRepository, type ClickStats } from './click.repository';

View file

@ -0,0 +1,144 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { DATABASE_TOKEN, type Database } from '../database.module';
import {
links,
type Link,
type NewLink,
eq,
and,
desc,
sql,
or,
ilike,
} from '@manacore/uload-database';
export interface ListLinksOptions {
page?: number;
limit?: number;
search?: string;
isActive?: boolean;
}
@Injectable()
export class LinkRepository {
private readonly logger = new Logger(LinkRepository.name);
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
async findByShortCode(shortCode: string): Promise<Link | null> {
const result = await this.db
.select()
.from(links)
.where(eq(links.shortCode, shortCode))
.limit(1);
return result[0] || null;
}
async findById(id: string): Promise<Link | null> {
const result = await this.db.select().from(links).where(eq(links.id, id)).limit(1);
return result[0] || null;
}
async findByIdAndUserId(id: string, userId: string): Promise<Link | null> {
const result = await this.db
.select()
.from(links)
.where(and(eq(links.id, id), eq(links.userId, userId)))
.limit(1);
return result[0] || null;
}
async findByUserId(
userId: string,
options: ListLinksOptions = {}
): Promise<{ items: Link[]; total: number }> {
const { page = 1, limit = 20, search, isActive } = options;
const offset = (page - 1) * limit;
const conditions = [eq(links.userId, userId)];
if (search) {
conditions.push(
or(
ilike(links.title, `%${search}%`),
ilike(links.originalUrl, `%${search}%`),
ilike(links.shortCode, `%${search}%`)
)!
);
}
if (isActive !== undefined) {
conditions.push(eq(links.isActive, isActive));
}
const [countResult, items] = await Promise.all([
this.db
.select({ count: sql<number>`count(*)::int` })
.from(links)
.where(and(...conditions)),
this.db
.select()
.from(links)
.where(and(...conditions))
.orderBy(desc(links.createdAt))
.limit(limit)
.offset(offset),
]);
return {
items,
total: countResult[0]?.count || 0,
};
}
async create(data: NewLink): Promise<Link> {
this.logger.debug(`Creating link: ${data.shortCode}`);
const result = await this.db.insert(links).values(data).returning();
return result[0];
}
async update(
id: string,
userId: string,
data: Partial<Omit<NewLink, 'id' | 'userId' | 'createdAt'>>
): Promise<Link | null> {
const result = await this.db
.update(links)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(links.id, id), eq(links.userId, userId)))
.returning();
return result[0] || null;
}
async delete(id: string, userId: string): Promise<boolean> {
const result = await this.db
.delete(links)
.where(and(eq(links.id, id), eq(links.userId, userId)))
.returning({ id: links.id });
return result.length > 0;
}
async incrementClickCount(id: string): Promise<void> {
await this.db
.update(links)
.set({ clickCount: sql`${links.clickCount} + 1` })
.where(eq(links.id, id));
}
async isShortCodeAvailable(shortCode: string): Promise<boolean> {
const result = await this.db
.select({ id: links.id })
.from(links)
.where(eq(links.shortCode, shortCode))
.limit(1);
return result.length === 0;
}
async countByUserId(userId: string): Promise<number> {
const result = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(links)
.where(eq(links.userId, userId));
return result[0]?.count || 0;
}
}

View file

@ -0,0 +1,47 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn', 'log', 'debug', 'verbose'],
});
const configService = app.get(ConfigService);
// CORS configuration
app.enableCors({
origin: configService.get('FRONTEND_URL') || true,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
});
// Global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
})
);
// Global prefix for API routes (except health and redirect)
app.setGlobalPrefix('v1', {
exclude: ['health', 'health/(.*)', ':code'],
});
const port = configService.get('PORT') || 3003;
await app.listen(port);
logger.log(`ULOAD Backend running on port ${port}`);
logger.log(`Health check: http://localhost:${port}/health`);
}
bootstrap();

View file

@ -0,0 +1,98 @@
import { Injectable, Logger } from '@nestjs/common';
import * as UAParser from 'ua-parser-js';
import { ClickRepository, type ClickStats } from '../database/repositories';
import { RedirectService } from './redirect.service';
import type { NewClick } from '@manacore/uload-database';
export interface RecordClickData {
userAgent: string;
referer?: string;
ip?: string;
utmSource?: string;
utmMedium?: string;
utmCampaign?: string;
}
@Injectable()
export class AnalyticsService {
private readonly logger = new Logger(AnalyticsService.name);
constructor(
private readonly clickRepository: ClickRepository,
private readonly redirectService: RedirectService
) {}
async recordClick(linkId: string, data: RecordClickData): Promise<void> {
try {
// Parse user agent
const parser = new UAParser.UAParser(data.userAgent);
const browser = parser.getBrowser();
const os = parser.getOS();
const device = parser.getDevice();
// Hash IP for privacy
const ipHash = data.ip ? this.hashIp(data.ip) : null;
// Determine device type
let deviceType = 'desktop';
if (device.type === 'mobile') {
deviceType = 'mobile';
} else if (device.type === 'tablet') {
deviceType = 'tablet';
}
const clickData: NewClick = {
linkId,
ipHash,
userAgent: data.userAgent,
referer: data.referer,
browser: browser.name || 'Unknown',
deviceType,
os: os.name || 'Unknown',
// TODO: Geo lookup from IP
country: null,
city: null,
utmSource: data.utmSource,
utmMedium: data.utmMedium,
utmCampaign: data.utmCampaign,
};
await this.clickRepository.create(clickData);
// Increment click count on the link
await this.redirectService.incrementClickCount(linkId);
this.logger.debug(`Recorded click for link ${linkId}`);
} catch (error) {
this.logger.error(`Failed to record click for link ${linkId}:`, error);
// Don't throw - click recording should not block redirect
}
}
async getStats(linkId: string, fromDate?: Date, toDate?: Date): Promise<ClickStats> {
return this.clickRepository.getStats(linkId, fromDate, toDate);
}
async getRecentClicks(
linkId: string,
limit: number = 100
): Promise<{ clicks: any[]; total: number }> {
const [clicks, total] = await Promise.all([
this.clickRepository.findByLinkId(linkId, { limit }),
this.clickRepository.countByLinkId(linkId),
]);
return { clicks, total };
}
private hashIp(ip: string): string {
// Simple hash for privacy - in production use a proper hash function
let hash = 0;
for (let i = 0; i < ip.length; i++) {
const char = ip.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
return hash.toString(16);
}
}

View file

@ -0,0 +1,137 @@
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { nanoid } from 'nanoid';
import { LinkRepository, type ListLinksOptions } from '../database/repositories';
import type { Link, NewLink } from '@manacore/uload-database';
export interface CreateLinkDto {
originalUrl: string;
customCode?: string;
title?: string;
description?: string;
password?: string;
maxClicks?: number;
expiresAt?: Date;
tags?: string[];
utmSource?: string;
utmMedium?: string;
utmCampaign?: string;
workspaceId?: string;
}
export interface UpdateLinkDto {
title?: string;
description?: string;
password?: string;
maxClicks?: number;
expiresAt?: Date;
isActive?: boolean;
tags?: string[];
utmSource?: string;
utmMedium?: string;
utmCampaign?: string;
}
@Injectable()
export class LinksService {
private readonly logger = new Logger(LinksService.name);
private readonly shortUrlBase: string;
constructor(
private readonly linkRepository: LinkRepository,
private readonly configService: ConfigService
) {
this.shortUrlBase = this.configService.get('SHORT_URL_BASE', 'https://ulo.ad');
}
async createLink(userId: string, dto: CreateLinkDto): Promise<Link> {
// Generate or validate short code
let shortCode = dto.customCode;
if (shortCode) {
// Validate custom code format
if (!/^[a-zA-Z0-9_-]+$/.test(shortCode)) {
throw new BadRequestException(
'Custom code can only contain letters, numbers, hyphens and underscores'
);
}
// Check if custom code is available
const isAvailable = await this.linkRepository.isShortCodeAvailable(shortCode);
if (!isAvailable) {
throw new BadRequestException('This custom code is already taken');
}
} else {
// Generate random short code
shortCode = nanoid(7);
// Make sure it's unique (very unlikely to collide, but check anyway)
let attempts = 0;
while (!(await this.linkRepository.isShortCodeAvailable(shortCode)) && attempts < 5) {
shortCode = nanoid(7);
attempts++;
}
}
const newLink: NewLink = {
shortCode,
customCode: dto.customCode,
originalUrl: dto.originalUrl,
title: dto.title,
description: dto.description,
userId,
password: dto.password, // TODO: Hash password if provided
maxClicks: dto.maxClicks,
expiresAt: dto.expiresAt,
tags: dto.tags,
utmSource: dto.utmSource,
utmMedium: dto.utmMedium,
utmCampaign: dto.utmCampaign,
workspaceId: dto.workspaceId,
};
const link = await this.linkRepository.create(newLink);
this.logger.log(`Created link ${link.shortCode} for user ${userId}`);
return link;
}
async updateLink(id: string, userId: string, dto: UpdateLinkDto): Promise<Link | null> {
const link = await this.linkRepository.update(id, userId, dto);
if (link) {
this.logger.log(`Updated link ${link.shortCode} for user ${userId}`);
}
return link;
}
async deleteLink(id: string, userId: string): Promise<boolean> {
const deleted = await this.linkRepository.delete(id, userId);
if (deleted) {
this.logger.log(`Deleted link ${id} for user ${userId}`);
}
return deleted;
}
async getLinkById(id: string, userId: string): Promise<Link | null> {
return this.linkRepository.findByIdAndUserId(id, userId);
}
async getLinks(
userId: string,
options: ListLinksOptions
): Promise<{ items: Link[]; total: number }> {
return this.linkRepository.findByUserId(userId, options);
}
async getLinkCount(userId: string): Promise<number> {
return this.linkRepository.countByUserId(userId);
}
getShortUrl(shortCode: string): string {
return `${this.shortUrlBase}/${shortCode}`;
}
}

View file

@ -0,0 +1,74 @@
import { Injectable, Logger } from '@nestjs/common';
import { LinkRepository } from '../database/repositories';
import type { Link } from '@manacore/uload-database';
export interface RedirectResult {
success: boolean;
targetUrl?: string;
linkId?: string;
error?: 'not_found' | 'expired' | 'inactive' | 'max_clicks' | 'password_required';
}
@Injectable()
export class RedirectService {
private readonly logger = new Logger(RedirectService.name);
constructor(private readonly linkRepository: LinkRepository) {}
async getRedirect(shortCode: string): Promise<RedirectResult> {
const link = await this.linkRepository.findByShortCode(shortCode);
if (!link) {
return { success: false, error: 'not_found' };
}
// Check if link is active
if (!link.isActive) {
return { success: false, error: 'inactive', linkId: link.id };
}
// Check if link has expired
if (link.expiresAt && new Date(link.expiresAt) < new Date()) {
return { success: false, error: 'expired', linkId: link.id };
}
// Check max clicks
if (link.maxClicks && (link.clickCount ?? 0) >= link.maxClicks) {
return { success: false, error: 'max_clicks', linkId: link.id };
}
// Check if password protected
if (link.password) {
return { success: false, error: 'password_required', linkId: link.id };
}
return {
success: true,
targetUrl: link.originalUrl,
linkId: link.id,
};
}
async verifyPassword(shortCode: string, password: string): Promise<RedirectResult> {
const link = await this.linkRepository.findByShortCode(shortCode);
if (!link) {
return { success: false, error: 'not_found' };
}
// TODO: Compare hashed passwords
if (link.password !== password) {
return { success: false, error: 'password_required', linkId: link.id };
}
return {
success: true,
targetUrl: link.originalUrl,
linkId: link.id,
};
}
async incrementClickCount(linkId: string): Promise<void> {
await this.linkRepository.incrementClickCount(linkId);
}
}