mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 01:02:12 +02:00
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:
parent
b97149ac12
commit
61d181fbc2
3148 changed files with 437 additions and 46640 deletions
75
apps-archived/uload/apps/backend/src/app.module.ts
Normal file
75
apps-archived/uload/apps/backend/src/app.module.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
});
|
||||
|
|
@ -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
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { LinkRepository, type ListLinksOptions } from './link.repository';
|
||||
export { ClickRepository, type ClickStats } from './click.repository';
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
47
apps-archived/uload/apps/backend/src/main.ts
Normal file
47
apps-archived/uload/apps/backend/src/main.ts
Normal 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();
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
137
apps-archived/uload/apps/backend/src/services/links.service.ts
Normal file
137
apps-archived/uload/apps/backend/src/services/links.service.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue