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,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);
}
}