chore: add techbase to apps-archived

Integrated techbase (software comparison platform) into monorepo structure:
- Created NestJS backend with votes and comments modules
- Migrated from external Supabase to own PostgreSQL
- Set up Drizzle ORM schema for votes and comments
- Created API client replacing Supabase in Astro frontend
- Added environment configuration (port 3021)

Archived immediately as it's not yet ready for active development.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-05 13:47:39 +01:00
parent 17313473aa
commit 34c879929b
161 changed files with 12613 additions and 0 deletions

View file

@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './db/database.module';
import { HealthModule } from './health/health.module';
import { VotesModule } from './votes/votes.module';
import { CommentsModule } from './comments/comments.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
HealthModule,
VotesModule,
CommentsModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,57 @@
import { Controller, Post, Get, Patch, Delete, Body, Param, Req } from '@nestjs/common';
import { Request } from 'express';
import { CommentsService } from './comments.service';
import { CreateCommentDto } from './dto/create-comment.dto';
@Controller('comments')
export class CommentsController {
constructor(private readonly commentsService: CommentsService) {}
@Post()
async createComment(@Body() createCommentDto: CreateCommentDto, @Req() req: Request) {
const ipAddress = req.ip || req.socket.remoteAddress || 'unknown';
return this.commentsService.createComment(
createCommentDto.softwareId,
createCommentDto.userName,
createCommentDto.comment,
ipAddress
);
}
@Get(':softwareId')
async getComments(@Param('softwareId') softwareId: string) {
return this.commentsService.getApprovedComments(softwareId);
}
}
@Controller('admin/comments')
export class AdminCommentsController {
constructor(private readonly commentsService: CommentsService) {}
@Get()
async getAllComments() {
return this.commentsService.getAllComments();
}
@Get('pending')
async getPendingComments() {
return this.commentsService.getPendingComments();
}
@Patch(':id/approve')
async approveComment(@Param('id') id: string) {
// TODO: Get actual moderator ID from auth
return this.commentsService.approveComment(id, 'admin');
}
@Patch(':id/reject')
async rejectComment(@Param('id') id: string) {
// TODO: Get actual moderator ID from auth
return this.commentsService.rejectComment(id, 'admin');
}
@Delete(':id')
async deleteComment(@Param('id') id: string) {
return this.commentsService.deleteComment(id);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CommentsController, AdminCommentsController } from './comments.controller';
import { CommentsService } from './comments.service';
@Module({
controllers: [CommentsController, AdminCommentsController],
providers: [CommentsService],
exports: [CommentsService],
})
export class CommentsModule {}

View file

@ -0,0 +1,91 @@
import { Injectable, Inject } from '@nestjs/common';
import { eq, and, desc } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { comments, type Comment, type NewComment } from '../db/schema';
import { createHash } from 'crypto';
@Injectable()
export class CommentsService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
private hashIp(ip: string): string {
return createHash('sha256').update(ip).digest('hex').substring(0, 32);
}
async createComment(
softwareId: string,
userName: string,
commentText: string,
ipAddress: string
): Promise<{ success: boolean; message: string }> {
const ipHash = this.hashIp(ipAddress);
const newComment: NewComment = {
softwareId,
userName,
comment: commentText,
ipHash,
isApproved: false,
isSpam: false,
};
await this.db.insert(comments).values(newComment);
return {
success: true,
message: 'Comment submitted successfully. It will be visible after moderation.',
};
}
async getApprovedComments(softwareId: string): Promise<Comment[]> {
return this.db
.select()
.from(comments)
.where(and(eq(comments.softwareId, softwareId), eq(comments.isApproved, true)))
.orderBy(desc(comments.createdAt));
}
async getAllComments(): Promise<Comment[]> {
return this.db.select().from(comments).orderBy(desc(comments.createdAt));
}
async getPendingComments(): Promise<Comment[]> {
return this.db
.select()
.from(comments)
.where(and(eq(comments.isApproved, false), eq(comments.isSpam, false)))
.orderBy(desc(comments.createdAt));
}
async approveComment(id: string, moderatorId: string): Promise<{ success: boolean }> {
await this.db
.update(comments)
.set({
isApproved: true,
moderatedAt: new Date(),
moderatedBy: moderatorId,
})
.where(eq(comments.id, id));
return { success: true };
}
async rejectComment(id: string, moderatorId: string): Promise<{ success: boolean }> {
await this.db
.update(comments)
.set({
isSpam: true,
moderatedAt: new Date(),
moderatedBy: moderatorId,
})
.where(eq(comments.id, id));
return { success: true };
}
async deleteComment(id: string): Promise<{ success: boolean }> {
await this.db.delete(comments).where(eq(comments.id, id));
return { success: true };
}
}

View file

@ -0,0 +1,19 @@
import { IsString, IsNotEmpty, MaxLength, MinLength } from 'class-validator';
export class CreateCommentDto {
@IsString()
@IsNotEmpty()
softwareId: string;
@IsString()
@IsNotEmpty()
@MinLength(2)
@MaxLength(100)
userName: string;
@IsString()
@IsNotEmpty()
@MinLength(10)
@MaxLength(2000)
comment: string;
}

View file

@ -0,0 +1,38 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import * as schema from './schema';
// Use require for postgres to avoid ESM/CommonJS interop issues
// eslint-disable-next-line @typescript-eslint/no-var-requires
const postgres = require('postgres');
let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getConnection(databaseUrl: string) {
if (!connection) {
connection = postgres(databaseUrl, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
}
return connection;
}
export function getDb(databaseUrl: string) {
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;

View file

@ -0,0 +1,28 @@
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getDb, closeConnection, type Database } from './connection';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeConnection();
}
}

View file

@ -0,0 +1,17 @@
import { pgTable, uuid, varchar, text, boolean, timestamp } from 'drizzle-orm/pg-core';
export const comments = pgTable('comments', {
id: uuid('id').defaultRandom().primaryKey(),
softwareId: varchar('software_id', { length: 255 }).notNull(),
userName: varchar('user_name', { length: 100 }).notNull(),
comment: text('comment').notNull(),
ipHash: varchar('ip_hash', { length: 255 }).notNull(),
isApproved: boolean('is_approved').default(false),
isSpam: boolean('is_spam').default(false),
moderatedAt: timestamp('moderated_at'),
moderatedBy: varchar('moderated_by', { length: 255 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export type Comment = typeof comments.$inferSelect;
export type NewComment = typeof comments.$inferInsert;

View file

@ -0,0 +1,2 @@
export * from './votes.schema';
export * from './comments.schema';

View file

@ -0,0 +1,19 @@
import { pgTable, uuid, varchar, integer, timestamp, unique } from 'drizzle-orm/pg-core';
export const votes = pgTable(
'votes',
{
id: uuid('id').defaultRandom().primaryKey(),
softwareId: varchar('software_id', { length: 255 }).notNull(),
metric: varchar('metric', { length: 50 }).notNull(),
rating: integer('rating').notNull(),
ipHash: varchar('ip_hash', { length: 255 }).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(table) => ({
uniqueVote: unique().on(table.softwareId, table.metric, table.ipHash),
})
);
export type Vote = typeof votes.$inferSelect;
export type NewVote = typeof votes.$inferInsert;

View file

@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
service: 'techbase-backend',
};
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View file

@ -0,0 +1,37 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable CORS for web app
const corsOrigins = process.env.CORS_ORIGINS?.split(',').map((origin) => origin.trim()) || [
'http://localhost:4321',
'http://localhost:5173',
'http://localhost:3000',
];
app.enableCors({
origin: corsOrigins,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
credentials: true,
});
// Enable validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
})
);
// Set global prefix for API routes
app.setGlobalPrefix('api');
const port = process.env.PORT || 3020;
await app.listen(port);
console.log(`TechBase backend running on http://localhost:${port}`);
}
bootstrap();

View file

@ -0,0 +1,16 @@
import { IsString, IsInt, Min, Max, IsNotEmpty } from 'class-validator';
export class CreateVoteDto {
@IsString()
@IsNotEmpty()
softwareId: string;
@IsString()
@IsNotEmpty()
metric: string;
@IsInt()
@Min(1)
@Max(5)
rating: number;
}

View file

@ -0,0 +1,30 @@
import { Controller, Post, Get, Body, Param, Req } from '@nestjs/common';
import { Request } from 'express';
import { VotesService } from './votes.service';
import { CreateVoteDto } from './dto/create-vote.dto';
@Controller('votes')
export class VotesController {
constructor(private readonly votesService: VotesService) {}
@Post()
async createVote(@Body() createVoteDto: CreateVoteDto, @Req() req: Request) {
const ipAddress = req.ip || req.socket.remoteAddress || 'unknown';
return this.votesService.createVote(
createVoteDto.softwareId,
createVoteDto.metric,
createVoteDto.rating,
ipAddress
);
}
@Get(':softwareId/metrics')
async getMetrics(@Param('softwareId') softwareId: string) {
return this.votesService.getAllMetrics(softwareId);
}
@Get(':softwareId/metrics/:metric')
async getMetricByName(@Param('softwareId') softwareId: string, @Param('metric') metric: string) {
return this.votesService.getMetrics(softwareId, metric);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { VotesController } from './votes.controller';
import { VotesService } from './votes.service';
@Module({
controllers: [VotesController],
providers: [VotesService],
exports: [VotesService],
})
export class VotesModule {}

View file

@ -0,0 +1,98 @@
import { Injectable, Inject } from '@nestjs/common';
import { eq, and, sql, avg, count } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { votes, type NewVote } from '../db/schema';
import { createHash } from 'crypto';
@Injectable()
export class VotesService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
private hashIp(ip: string): string {
return createHash('sha256').update(ip).digest('hex').substring(0, 32);
}
async createVote(
softwareId: string,
metric: string,
rating: number,
ipAddress: string
): Promise<{ success: boolean; newAverage: number; voteCount: number }> {
const ipHash = this.hashIp(ipAddress);
// Check if user already voted for this metric
const existingVote = await this.db
.select()
.from(votes)
.where(and(eq(votes.softwareId, softwareId), eq(votes.metric, metric), eq(votes.ipHash, ipHash)))
.limit(1);
if (existingVote.length > 0) {
// Update existing vote
await this.db
.update(votes)
.set({ rating, createdAt: new Date() })
.where(eq(votes.id, existingVote[0].id));
} else {
// Create new vote
const newVote: NewVote = {
softwareId,
metric,
rating,
ipHash,
};
await this.db.insert(votes).values(newVote);
}
// Get updated metrics
const metrics = await this.getMetrics(softwareId, metric);
return {
success: true,
newAverage: metrics.averageRating,
voteCount: metrics.voteCount,
};
}
async getMetrics(
softwareId: string,
metric?: string
): Promise<{ averageRating: number; voteCount: number }> {
const conditions = metric
? and(eq(votes.softwareId, softwareId), eq(votes.metric, metric))
: eq(votes.softwareId, softwareId);
const result = await this.db
.select({
averageRating: avg(votes.rating),
voteCount: count(votes.id),
})
.from(votes)
.where(conditions);
return {
averageRating: parseFloat(result[0]?.averageRating || '0') || 0,
voteCount: result[0]?.voteCount || 0,
};
}
async getAllMetrics(
softwareId: string
): Promise<{ metric: string; averageRating: number; voteCount: number }[]> {
const result = await this.db
.select({
metric: votes.metric,
averageRating: avg(votes.rating),
voteCount: count(votes.id),
})
.from(votes)
.where(eq(votes.softwareId, softwareId))
.groupBy(votes.metric);
return result.map((r) => ({
metric: r.metric,
averageRating: parseFloat(r.averageRating || '0') || 0,
voteCount: r.voteCount || 0,
}));
}
}