mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-26 00:24:38 +02:00
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:
parent
17313473aa
commit
34c879929b
161 changed files with 12613 additions and 0 deletions
20
apps-archived/techbase/apps/backend/src/app.module.ts
Normal file
20
apps-archived/techbase/apps/backend/src/app.module.ts
Normal 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 {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
38
apps-archived/techbase/apps/backend/src/db/connection.ts
Normal file
38
apps-archived/techbase/apps/backend/src/db/connection.ts
Normal 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>;
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './votes.schema';
|
||||
export * from './comments.schema';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
37
apps-archived/techbase/apps/backend/src/main.ts
Normal file
37
apps-archived/techbase/apps/backend/src/main.ts
Normal 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();
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue