diff --git a/apps/api/jest.config.js b/apps/api/jest.config.js new file mode 100644 index 0000000..d8ac138 --- /dev/null +++ b/apps/api/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: 'src', + testRegex: '.*\\.spec\\.ts$', + transform: { '^.+\\.(t|j)s$': 'ts-jest' }, + testEnvironment: 'node', +}; diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/apps/api/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..d28f24f --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,34 @@ +{ + "name": "@tower/api", + "version": "0.0.1", + "scripts": { + "build": "nest build", + "dev": "nest start --watch", + "start": "node dist/main", + "test": "jest", + "test:e2e": "jest --config ./test/jest-e2e.json" + }, + "dependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/config": "^4.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@prisma/client": "^6.0.0", + "@tower/config": "workspace:*", + "@tower/logger": "workspace:*", + "@tower/types": "workspace:*", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.0" + }, + "devDependencies": { + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.0", + "@types/jest": "^29.0.0", + "@types/node": "^22.0.0", + "jest": "^29.0.0", + "prisma": "^6.0.0", + "ts-jest": "^29.0.0", + "typescript": "^5.7.0" + } +} diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma new file mode 100644 index 0000000..2f72ab6 --- /dev/null +++ b/apps/api/prisma/schema.prisma @@ -0,0 +1,93 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Group { + id String @id @default(cuid()) + platform String + platformId String + name String + description String? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + messages Message[] + syncRoutesFrom SyncRoute[] @relation("sourceGroup") + syncRoutesTo SyncRoute[] @relation("targetGroup") + consentRecords ConsentRecord[] + + @@unique([platform, platformId]) +} + +model Message { + id String @id @default(cuid()) + platform String + platformMsgId String + sourceGroupId String + sourceGroup Group @relation(fields: [sourceGroupId], references: [id]) + senderJid String + senderName String? + content String + mediaUrl String? + tags String[] + status MessageStatus @default(PENDING) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + approval Approval? + + @@unique([platform, platformMsgId]) +} + +enum MessageStatus { + PENDING + APPROVED + REJECTED + DISTRIBUTED + ARCHIVED +} + +model Approval { + id String @id @default(cuid()) + messageId String @unique + message Message @relation(fields: [messageId], references: [id]) + adminId String + decision ApprovalDecision + notes String? + decidedAt DateTime @default(now()) +} + +enum ApprovalDecision { + APPROVED + REJECTED +} + +model SyncRoute { + id String @id @default(cuid()) + sourceGroupId String + sourceGroup Group @relation("sourceGroup", fields: [sourceGroupId], references: [id]) + targetGroupId String + targetGroup Group @relation("targetGroup", fields: [targetGroupId], references: [id]) + isActive Boolean @default(true) + createdAt DateTime @default(now()) + + @@unique([sourceGroupId, targetGroupId]) +} + +model ConsentRecord { + id String @id @default(cuid()) + groupId String + group Group @relation(fields: [groupId], references: [id]) + memberJid String + consentType String + grantedAt DateTime @default(now()) + revokedAt DateTime? + + @@unique([groupId, memberJid, consentType]) +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 0000000..0abdab7 --- /dev/null +++ b/apps/api/src/app.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { PrismaModule } from './prisma/prisma.module'; +import { HealthModule } from './modules/health/health.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + PrismaModule, + HealthModule, + ], +}) +export class AppModule {} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..dd42ec7 --- /dev/null +++ b/apps/api/src/main.ts @@ -0,0 +1,12 @@ +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + const port = process.env['API_PORT'] ?? 3001; + await app.listen(port); + console.log(`TOWER API running on port ${port}`); +} + +bootstrap(); diff --git a/apps/api/src/modules/health/health.controller.ts b/apps/api/src/modules/health/health.controller.ts new file mode 100644 index 0000000..73f6be0 --- /dev/null +++ b/apps/api/src/modules/health/health.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { + status: 'ok', + service: 'tower-api', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/apps/api/src/modules/health/health.module.ts b/apps/api/src/modules/health/health.module.ts new file mode 100644 index 0000000..7476abe --- /dev/null +++ b/apps/api/src/modules/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/apps/api/src/prisma/prisma.module.ts b/apps/api/src/prisma/prisma.module.ts new file mode 100644 index 0000000..7207426 --- /dev/null +++ b/apps/api/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts new file mode 100644 index 0000000..7ffd32d --- /dev/null +++ b/apps/api/src/prisma/prisma.service.ts @@ -0,0 +1,16 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..a5fbae0 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true + } +}