diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f1cc3ad --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +# http://editorconfig.org + +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +insert_final_newline = false +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..07d482f --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Backend Environment Variables +NODE_ENV=development +DATABASE_URL=postgresql://user:password@db:5432/acme_db + +OPENAI_API_KEY=YOUR_OPEN_AI_API_KEY_HERE +ANTHROPIC_API_KEY=YOUR_ANTHROPIC_API_KEY_HERE \ No newline at end of file diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..b687353 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,11 @@ +{ + "root": true, + "extends": ["eslint-config-base"], + "parserOptions": { + "sourceType": "module", + "ecmaVersion": 2020, + "ecmaFeatures": { + "jsx": true + } + } +} diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..54e3daf --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,75 @@ +# GitHub Copilot Instructions for Turborepo Monorepo + +## Core Guidelines + +1. **Check Documentation First** + - Always reference the `docs/` folder for technology-specific guides and patterns + - Follow the README.md for setup, testing, and development instructions + - Consult official documentation when uncertain + +2. **Code Quality** + - Run `npm run format` to format code automatically + - Run `npm run lint:fix` to fix linting issues + - Keep code clean, typed, and well-structured + - Use TypeScript properly and avoid `any` types + +3. **Monorepo Structure** + - Follow the established package organization in `apps/` and `packages/` + - Check `docs/PROJECT_STRUCTURE.md` for monorepo patterns + - Keep packages focused and well-defined + - Use workspace dependencies appropriately + +4. **Development Workflow** + - Test changes using the commands in README.md + - Follow the project's established patterns and conventions + - Reference existing code for consistency + - Use Turborepo's caching and task orchestration + +5. **Best Practices** + - Write clean, readable, and maintainable code + - Use proper error handling and validation + - Follow monorepo best practices for scalability + - Implement proper package dependencies + +## When Suggesting Code + +1. **Package Creation** + - Check if similar packages exist in the monorepo + - Look for relevant guides in the `docs/` folder + - Follow established package structure patterns + - Use proper package.json configuration + - Implement appropriate build and export patterns + +2. **Dependency Management** + - Use workspace dependencies for internal packages + - Follow established patterns for external dependencies + - Avoid duplicate dependencies across packages + - Use proper versioning strategies + +3. **Shared Code** + - Create reusable packages in `packages/` directory + - Follow established patterns for shared components + - Implement proper TypeScript exports + - Use consistent naming conventions + +4. **App Development** + - Place applications in `apps/` directory + - Use shared packages appropriately + - Follow established patterns for each app type + - Implement proper build configurations + +5. **Build & Development** + - Use Turborepo's task pipelines effectively + - Implement proper caching strategies + - Follow established build patterns + - Use remote caching when configured + +## Turborepo Specific + +- Leverage Turborepo's parallel execution and caching +- Use proper task dependencies in turbo.json +- Implement incremental builds effectively +- Follow workspace patterns for package dependencies +- Use Turborepo's remote caching when available +- Implement proper change detection for CI/CD +- Follow established patterns for package publishing diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97a9fb8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules +.pnp +.pnp.js + +# testing +coverage + +# next.js +.next/ +out/ +build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# turbo +.turbo +dist + +# Storybook +out +storybook-static/ + +app/ diff --git a/.lintstagedrc.json b/.lintstagedrc.json new file mode 100644 index 0000000..1f09bb7 --- /dev/null +++ b/.lintstagedrc.json @@ -0,0 +1,3 @@ +{ + "*": ["prettier -u --write"] +} diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..7c226a0 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,8 @@ +{ + "MD041": false, + "MD042": false, + "MD004": false, + "MD013": false, + "MD033": false, + "MD024": false +} diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..ef220b0 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +v20.19.4 \ No newline at end of file diff --git a/apps/backend/.dockerignore b/apps/backend/.dockerignore new file mode 100644 index 0000000..ae1db91 --- /dev/null +++ b/apps/backend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.git +build +dist +tools diff --git a/apps/backend/.env.example b/apps/backend/.env.example new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/.eslitignore b/apps/backend/.eslitignore new file mode 100644 index 0000000..42ee296 --- /dev/null +++ b/apps/backend/.eslitignore @@ -0,0 +1,10 @@ +# /node_modules/* in the project root is ignored by default +# build artefacts +dist/* +coverage/* +node_modules/* +logs/* +prod/* +.husky/* +.github/* +tools/ \ No newline at end of file diff --git a/apps/backend/.github/copilot-instructions.md b/apps/backend/.github/copilot-instructions.md new file mode 100644 index 0000000..f26abf9 --- /dev/null +++ b/apps/backend/.github/copilot-instructions.md @@ -0,0 +1,76 @@ +# GitHub Copilot Instructions for NestJS Project + +## Core Guidelines + +1. **Check Documentation First** + - Always reference the `docs/` folder for technology-specific guides and patterns + - Follow the README.md for setup, testing, and development instructions + - Consult official documentation when uncertain + +2. **Code Quality** + - Run `npm run format` to format code automatically + - Run `npm run lint:fix` to fix linting issues + - Keep code clean, typed, and well-structured + - Use TypeScript properly and avoid `any` types + +3. **Project Structure** + - Follow NestJS module-based architecture + - Use proper dependency injection patterns + - Keep services, controllers, and modules organized + - Follow NestJS best practices for scalability + +4. **Development Workflow** + - Test changes using the commands in README.md + - Follow the project's established patterns and conventions + - Reference existing code for consistency + - Use proper error handling and validation + +5. **Best Practices** + - Write clean, readable, and maintainable code + - Use proper DTOs for data validation + - Follow REST API and GraphQL conventions + - Implement proper error responses and logging + +## When Suggesting Code + +1. **Module Creation** + - Check if similar modules exist in the codebase + - Look for relevant guides in the `docs/` folder + - Follow NestJS module structure patterns + - Use proper dependency injection + - Implement appropriate guards, interceptors, and pipes + +2. **API Design** + - Use proper HTTP methods and status codes + - Implement comprehensive DTOs with validation + - Follow established API patterns in the project + - Use OpenAPI/Swagger decorators if configured + - Implement proper error handling + +3. **Database Integration** + - Reference `docs/` for ORM/database patterns used in the project + - Follow established entity and repository patterns + - Use proper transaction handling + - Implement data validation and sanitization + +4. **Authentication & Authorization** + - Follow established security patterns + - Use proper JWT handling if implemented + - Implement role-based access control appropriately + - Follow security best practices + +5. **Testing** + - Suggest appropriate unit and integration tests + - Follow testing patterns established in the project + - Mock dependencies properly + - Test both success and error scenarios + +## NestJS Specific + +- Use decorators appropriately (@Injectable, @Controller, etc.) +- Implement proper exception filters +- Use NestJS built-in validation with class-validator +- Follow dependency injection best practices +- Implement proper logging with NestJS logger +- Use configuration management properly +- Implement health checks and monitoring diff --git a/apps/backend/.gitignore b/apps/backend/.gitignore new file mode 100644 index 0000000..5c69b1f --- /dev/null +++ b/apps/backend/.gitignore @@ -0,0 +1,400 @@ +# Created by .ignore support plugin (hsz.mobi) +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/dictionaries + +# Sensitive or high-churn files: +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.xml +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml + +# Gradle: +.idea/**/gradle.xml +.idea/**/libraries + +# CMake +cmake-build-debug/ + +# Mongo Explorer plugin: +.idea/**/mongoSettings.xml + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties +### VisualStudio template +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +**/Properties/launchSettings.json + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Typescript v1 declaration files +typings/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ +coverage/ + +### macOS template +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +======= +# Local +.env +dist +.webpack +.serverless/**/*.zip diff --git a/apps/backend/.prettierrc.js b/apps/backend/.prettierrc.js new file mode 100644 index 0000000..13c2be1 --- /dev/null +++ b/apps/backend/.prettierrc.js @@ -0,0 +1,7 @@ +module.exports = { + semi: true, + trailingComma: 'all', + singleQuote: true, + printWidth: 120, + tabWidth: 2, +}; diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile new file mode 100644 index 0000000..a235c57 --- /dev/null +++ b/apps/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package*.json ./ + +RUN npm install + +COPY . . + +RUN npm run build + +EXPOSE 3000 + +CMD ["npm", "run", "start:dev"] diff --git a/apps/backend/README.md b/apps/backend/README.md new file mode 100644 index 0000000..d30c946 --- /dev/null +++ b/apps/backend/README.md @@ -0,0 +1,98 @@ +

+ Nest Logo +

+ +[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 +[circleci-url]: https://circleci.com/gh/nestjs/nest + +

A progressive Node.js framework for building efficient and scalable server-side applications.

+

+NPM Version +Package License +NPM Downloads +CircleCI +Discord +Backers on Open Collective +Sponsors on Open Collective + Donate us + Support us + Follow us on Twitter +

+ + +## Description + +[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. + +## Project setup + +```bash +$ pnpm install +``` + +## Compile and run the project + +```bash +# development +$ pnpm run start + +# watch mode +$ pnpm run start:dev + +# production mode +$ pnpm run start:prod +``` + +## Run tests + +```bash +# unit tests +$ pnpm run test + +# e2e tests +$ pnpm run test:e2e + +# test coverage +$ pnpm run test:cov +``` + +## Deployment + +When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information. + +If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps: + +```bash +$ pnpm install -g @nestjs/mau +$ mau deploy +``` + +With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure. + +## Resources + +Check out a few resources that may come in handy when working with NestJS: + +- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework. +- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy). +- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/). +- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks. +- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com). +- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com). +- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs). +- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com). + +## Support + +Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). + +## Stay in touch + +- Author - [Kamil Myƛliwiec](https://twitter.com/kammysliwiec) +- Website - [https://nestjs.com](https://nestjs.com/) +- Twitter - [@nestframework](https://twitter.com/nestframework) + +## License + +Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE). diff --git a/apps/backend/docs/README.md b/apps/backend/docs/README.md new file mode 100644 index 0000000..da9e386 --- /dev/null +++ b/apps/backend/docs/README.md @@ -0,0 +1,7 @@ +# Extra Documentation + +This folder contains extra documentation for the project such us project structure, configuration, etc. + +## Resources + +- [NestJS Documentation](https://docs.nestjs.com/) - Official NestJS documentation diff --git a/apps/backend/eslint.config.mjs b/apps/backend/eslint.config.mjs new file mode 100644 index 0000000..366de4d --- /dev/null +++ b/apps/backend/eslint.config.mjs @@ -0,0 +1,37 @@ +// @ts-check +import eslint from '@eslint/js'; +import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; +import parser from '@typescript-eslint/parser'; + +export default [ + { + ignores: ['eslint.config.mjs'], + }, + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + eslintPluginPrettierRecommended, + { + languageOptions: { + globals: { + ...globals.node, + ...globals.jest, + }, + sourceType: 'commonjs', + parserOptions: { + parser, + project: ['./tsconfig.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + rules: { + '@typescript-eslint/no-unsafe-call': 'error', + '@typescript-eslint/no-unsafe-member-access': 'error', + '@typescript-eslint/no-unsafe-assignment': 'error', + '@typescript-eslint/no-unsafe-return': 'error', + }, + }, +]; diff --git a/apps/backend/jest.config.js b/apps/backend/jest.config.js new file mode 100644 index 0000000..3b45711 --- /dev/null +++ b/apps/backend/jest.config.js @@ -0,0 +1,11 @@ +export default { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: 'src', + testRegex: '.*\\.spec\\.ts$', + transform: { + '^.+\\.(t|j)s$': 'ts-jest', + }, + collectCoverageFrom: ['**/*.(t|j)s'], + coverageDirectory: '../coverage', + testEnvironment: 'node', +}; diff --git a/apps/backend/nest-cli.json b/apps/backend/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/apps/backend/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/backend/package.json b/apps/backend/package.json new file mode 100644 index 0000000..d33427c --- /dev/null +++ b/apps/backend/package.json @@ -0,0 +1,91 @@ +{ + "name": "backend", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json" + }, + "dependencies": { + "@apollo/server": "^4.12.2", + "@langchain/anthropic": "^0.3.27", + "@langchain/core": "^0.3.75", + "@langchain/openai": "^0.6.11", + "@nestjs/apollo": "^13.1.0", + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11.0.1", + "@nestjs/graphql": "^13.1.0", + "@nestjs/mapped-types": "^2.1.0", + "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.2.0", + "@nestjs/typeorm": "^11.0.0", + "apollo-server-express": "^3.13.0", + "class-validator": "^0.14.2", + "graphql": "^16.11.0", + "graphql-subscriptions": "^3.0.0", + "langchain": "^0.3.33", + "pg": "^8.16.3", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "swagger-ui-express": "^5.0.1", + "typeorm": "^0.3.26" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.33.0", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@types/express": "^5.0.0", + "@types/jest": "^30.0.0", + "@types/node": "^22.10.7", + "@types/supertest": "^6.0.2", + "@typescript-eslint/eslint-plugin": "^8.42.0", + "@typescript-eslint/parser": "^8.42.0", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", + "globals": "^16.0.0", + "jest": "^30.0.0", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.3", + "typescript-eslint": "^8.39.1" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts new file mode 100644 index 0000000..3d567a0 --- /dev/null +++ b/apps/backend/src/app.module.ts @@ -0,0 +1,45 @@ +import { join } from 'path'; +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { GraphQLModule } from '@nestjs/graphql'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; +import { CampaignsModule } from './campaigns/campaigns.module'; +import { ContentPiecesModule } from './content-pieces/content-pieces.module'; +import { ContentPieceTranslationsModule } from './content-piece-translations/content-piece-translations.module'; +import { LangChainModule } from './langchain/langchain.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + }), + TypeOrmModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + type: 'postgres', + url: configService.get('DATABASE_URL'), + autoLoadEntities: true, + synchronize: true, // In production, use migrations + }), + inject: [ConfigService], + }), + GraphQLModule.forRoot({ + driver: ApolloDriver, + autoSchemaFile: join(process.cwd(), 'src/schema.gql'), + sortSchema: true, + subscriptions: { + 'graphql-ws': true, + }, + }), + + // Feature modules + CampaignsModule, + ContentPiecesModule, + ContentPieceTranslationsModule, + LangChainModule, + ], + controllers: [], + providers: [], +}) +export class AppModule {} diff --git a/apps/backend/src/app.service.ts b/apps/backend/src/app.service.ts new file mode 100644 index 0000000..927d7cc --- /dev/null +++ b/apps/backend/src/app.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class AppService { + getHello(): string { + return 'Hello World!'; + } +} diff --git a/apps/backend/src/campaigns/campaign.entity.ts b/apps/backend/src/campaigns/campaign.entity.ts new file mode 100644 index 0000000..3bee30f --- /dev/null +++ b/apps/backend/src/campaigns/campaign.entity.ts @@ -0,0 +1,35 @@ +import { Entity, PrimaryGeneratedColumn, CreateDateColumn, Column, OneToMany, UpdateDateColumn } from 'typeorm'; +import { Field, ID, ObjectType } from '@nestjs/graphql'; +import { ContentPiece } from '../content-pieces/content-piece.entity'; + +@ObjectType() +@Entity() +export class Campaign { + @Field(() => ID) + @PrimaryGeneratedColumn('uuid') + id: string; + + @Field() + @Column() + name: string; + + @Field() + @Column() + description: string; + + @Field() + @CreateDateColumn() + createdAt: Date; + + @Field() + @UpdateDateColumn() + updatedAt: Date; + + @Field(() => [ContentPiece], { defaultValue: [] }) + @OneToMany(() => ContentPiece, (contentPiece) => contentPiece.campaign) + contentPieces: ContentPiece[]; + + // For subscription purposes + @Field() + _type: string; +} diff --git a/apps/backend/src/campaigns/campaigns.controller.ts b/apps/backend/src/campaigns/campaigns.controller.ts new file mode 100644 index 0000000..02b5361 --- /dev/null +++ b/apps/backend/src/campaigns/campaigns.controller.ts @@ -0,0 +1,56 @@ +import { Controller, Get, Post, Body, Param, NotFoundException, Delete, ParseUUIDPipe } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiParam } from '@nestjs/swagger'; +import { CampaignsService } from './campaigns.service'; +import { CreateCampaignDto } from './dto/create-campaign.dto'; +import { Campaign } from './campaign.entity'; + +@ApiTags('campaigns') +@Controller('campaigns') +export class CampaignsController { + constructor(private readonly campaignsService: CampaignsService) {} + + @Post() + @ApiOperation({ summary: 'Create a new campaign' }) + async create(@Body() createCampaignDto: CreateCampaignDto): Promise { + return this.campaignsService.create(createCampaignDto); + } + + @Get() + @ApiOperation({ summary: 'Get all campaigns' }) + async findAll(): Promise { + // This endpoint returns a list of all campaigns. + return this.campaignsService.findAll(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a campaign by ID' }) + @ApiParam({ name: 'id', description: 'The ID of the campaign' }) + async findOne(@Param('id', new ParseUUIDPipe()) id: string): Promise { + // This endpoint returns a single campaign by its ID. + try { + return await this.campaignsService.findOne(id); + } catch (error) { + // Handle the case where the campaign is not found. + if (error instanceof NotFoundException) { + throw new NotFoundException(error.message); + } + throw error; + } + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a campaign by ID' }) + @ApiParam({ name: 'id', description: 'The ID of the campaign' }) + async remove(@Param('id', new ParseUUIDPipe()) id: string): Promise { + // This endpoint deletes a campaign by its ID. + try { + return await this.campaignsService.remove(id); + } catch (error) { + // Handle the case where the campaign is not found. + if (error instanceof NotFoundException) { + throw new NotFoundException(error.message); + } + throw error; + } + } +} diff --git a/apps/backend/src/campaigns/campaigns.module.ts b/apps/backend/src/campaigns/campaigns.module.ts new file mode 100644 index 0000000..6711401 --- /dev/null +++ b/apps/backend/src/campaigns/campaigns.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Campaign } from './campaign.entity'; +import { CampaignsService } from './campaigns.service'; +import { CampaignsController } from './campaigns.controller'; +import { CampaignResolver } from './campaigns.resolver'; +import { PubSub } from 'graphql-subscriptions'; +import { ContentPiecesModule } from 'src/content-pieces/content-pieces.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([Campaign]), ContentPiecesModule], + providers: [ + CampaignsService, + CampaignResolver, + { + provide: PubSub, + useValue: new PubSub(), + }, + ], + controllers: [CampaignsController], + exports: [CampaignsService], +}) +export class CampaignsModule {} diff --git a/apps/backend/src/campaigns/campaigns.resolver.ts b/apps/backend/src/campaigns/campaigns.resolver.ts new file mode 100644 index 0000000..9de1d69 --- /dev/null +++ b/apps/backend/src/campaigns/campaigns.resolver.ts @@ -0,0 +1,60 @@ +import { Resolver, Query, Mutation, Args, ID, Subscription, ResolveField, Parent } from '@nestjs/graphql'; +import { PubSub } from 'graphql-subscriptions'; +import { CampaignsService } from './campaigns.service'; +import { Campaign } from './campaign.entity'; +import { ContentPiece } from 'src/content-pieces/content-piece.entity'; +import { ContentPiecesService } from 'src/content-pieces/content-pieces.service'; + +@Resolver(() => Campaign) +export class CampaignResolver { + constructor( + private readonly campaignsService: CampaignsService, + private readonly contentPiecesService: ContentPiecesService, + private readonly pubSub: PubSub, + ) {} + + @Query(() => [Campaign]) + async campaigns(): Promise { + return this.campaignsService.findAll(); + } + + @Query(() => Campaign) + async campaign(@Args('id', { type: () => ID }) id: string): Promise { + return this.campaignsService.findOne(id); + } + + @Subscription(() => Campaign, { + name: 'campaignUpdated', + }) + campaignUpdated() { + return this.pubSub.asyncIterableIterator('campaignUpdated'); + } + + @ResolveField(() => [ContentPiece]) + async contentPieces(@Parent() campaign: Campaign): Promise { + const data = await this.contentPiecesService.findAll(campaign.id); + return data; + } + + @Mutation(() => Campaign) + async createCampaign(@Args('name') name: string, @Args('description') description: string): Promise { + const campaign = await this.campaignsService.create({ name, description }); + return campaign; + } + + @Mutation(() => Campaign) + async updateCampaign( + @Args('id', { type: () => ID }) id: string, + @Args('name') name?: string, + @Args('description') description?: string, + ): Promise { + const campaign = await this.campaignsService.update(id, { name, description }); + return campaign; + } + + @Mutation(() => ID) + async removeCampaign(@Args('id', { type: () => ID }) id: string): Promise { + await this.campaignsService.remove(id); + return id; + } +} diff --git a/apps/backend/src/campaigns/campaigns.service.ts b/apps/backend/src/campaigns/campaigns.service.ts new file mode 100644 index 0000000..751da9b --- /dev/null +++ b/apps/backend/src/campaigns/campaigns.service.ts @@ -0,0 +1,58 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Campaign } from './campaign.entity'; +import { PubSub } from 'graphql-subscriptions'; + +@Injectable() +export class CampaignsService { + constructor( + @InjectRepository(Campaign) + private readonly campaignRepository: Repository, + private readonly pubSub: PubSub, + ) {} + + async create(createCampaignDto: Partial): Promise { + const entity = this.campaignRepository.create(createCampaignDto); + const newCampaign = await this.campaignRepository.save(entity); + + await this.pubSub.publish('campaignUpdated', { campaignUpdated: { ...newCampaign, _type: 'create' } }); + return newCampaign; + } + + async findOne(id: string): Promise { + const campaign = await this.campaignRepository.findOne({ + where: { id }, + order: { updatedAt: 'DESC' }, + relations: ['contentPieces'], + }); + if (!campaign) { + throw new NotFoundException(`Campaign with ID ${id} not found`); + } + return campaign; + } + + async findAll(): Promise { + return this.campaignRepository.find({ + relations: ['contentPieces'], + order: { updatedAt: 'ASC', createdAt: 'DESC' }, + }); + } + + async update(id: string, updateCampaignDto: Partial): Promise { + const entity = await this.findOne(id); + Object.assign(entity, updateCampaignDto); + const campaign = await this.campaignRepository.save(entity); + + await this.pubSub.publish('campaignUpdated', { campaignUpdated: { ...campaign, _type: 'update' } }); + return campaign; + } + + async remove(id: string): Promise { + const entity = await this.findOne(id); + const result = await this.campaignRepository.remove(entity); + + await this.pubSub.publish('campaignUpdated', { campaignUpdated: { ...result, id: id, _type: 'remove' } }); + return result; + } +} diff --git a/apps/backend/src/campaigns/dto/create-campaign.dto.ts b/apps/backend/src/campaigns/dto/create-campaign.dto.ts new file mode 100644 index 0000000..245a322 --- /dev/null +++ b/apps/backend/src/campaigns/dto/create-campaign.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty } from 'class-validator'; + +export class CreateCampaignDto { + @ApiProperty({ + description: 'The name of the campaign', + example: 'Summer Socials 2025', + }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty({ + description: 'A brief description of the campaign', + example: 'Marketing content for the summer product line.', + }) + @IsString() + @IsNotEmpty() + description: string; +} diff --git a/apps/backend/src/content-piece-translations/content-piece-translations.controller.ts b/apps/backend/src/content-piece-translations/content-piece-translations.controller.ts new file mode 100644 index 0000000..8ec23eb --- /dev/null +++ b/apps/backend/src/content-piece-translations/content-piece-translations.controller.ts @@ -0,0 +1,70 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Patch, + Delete, + NotFoundException, + ParseUUIDPipe, + Query, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiParam } from '@nestjs/swagger'; +import { ContentPieceTranslationService } from './content-piece-translations.service'; +import { ContentPieceTranslation } from './content-piece-translations.entity'; +import { CreateContentPieceTranslationDto } from './dto/create-content-piece-translations.dto'; +import { UpdateContentPieceTranslationDto } from './dto/update-content-piece-translations.dto'; + +@ApiTags('content-piece-translations') +@Controller('content-piece-translations') +export class ContentPieceTranslationController { + constructor(private readonly translationService: ContentPieceTranslationService) {} + + @Post() + @ApiOperation({ summary: 'Create a new content piece translation' }) + async create(@Body() createTranslationDto: CreateContentPieceTranslationDto): Promise { + return this.translationService.create(createTranslationDto); + } + + @Get() + @ApiOperation({ summary: 'Get all content piece translations' }) + async findAll(@Query('contentPieceId') contentPieceId: string | undefined): Promise { + if (contentPieceId) { + return this.translationService.findAll(contentPieceId); + } + + return this.translationService.findAll(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a content piece translation by ID' }) + @ApiParam({ name: 'id', description: 'The ID of the translation' }) + async findOne(@Param('id', new ParseUUIDPipe()) id: string): Promise { + try { + return await this.translationService.findOne(id); + } catch (error) { + if (error instanceof NotFoundException) { + throw new NotFoundException(error.message); + } + throw error; + } + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a content piece translation by ID' }) + @ApiParam({ name: 'id', description: 'The ID of the translation to update' }) + async update( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() updateTranslationDto: UpdateContentPieceTranslationDto, + ): Promise { + return this.translationService.update(id, updateTranslationDto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a content piece translation by ID' }) + @ApiParam({ name: 'id', description: 'The ID of the translation to delete' }) + async remove(@Param('id', new ParseUUIDPipe()) id: string): Promise { + return this.translationService.remove(id); + } +} diff --git a/apps/backend/src/content-piece-translations/content-piece-translations.entity.ts b/apps/backend/src/content-piece-translations/content-piece-translations.entity.ts new file mode 100644 index 0000000..8b84c0f --- /dev/null +++ b/apps/backend/src/content-piece-translations/content-piece-translations.entity.ts @@ -0,0 +1,58 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { Field, ID, ObjectType } from '@nestjs/graphql'; +import { ContentPiece } from '../content-pieces/content-piece.entity'; +import { ModelProvider } from 'src/langchain/langchain.enum'; + +@ObjectType() +@Entity() +export class ContentPieceTranslation { + @Field(() => ID) + @PrimaryGeneratedColumn('uuid') + id: string; + + @Field(() => ModelProvider, { nullable: true }) + @Column({ type: 'enum', enum: ModelProvider, nullable: true }) + modelProvider: ModelProvider; + + @Field() + @Column() + languageCode: string; + + @Field() + @Column() + translatedTitle: string; + + @Field() + @Column() + translatedDescription: string; + + @Field() + @Column({ default: false }) + isAIGenerated: boolean; + + @Field() + @Column({ default: false }) + isHumanEdited: boolean; + + @Field() + @CreateDateColumn() + createdAt: Date; + + @Field() + @UpdateDateColumn() + updatedAt: Date; + + @Field(() => ContentPiece) + @ManyToOne(() => ContentPiece, (contentPiece): ContentPieceTranslation[] => contentPiece.translations) + contentPiece: ContentPiece; + + // For subscription purposes + @Field() + _type: string; + + @Field() + campaignId: string; + + @Field() + contentPieceId: string; +} diff --git a/apps/backend/src/content-piece-translations/content-piece-translations.module.ts b/apps/backend/src/content-piece-translations/content-piece-translations.module.ts new file mode 100644 index 0000000..223efb1 --- /dev/null +++ b/apps/backend/src/content-piece-translations/content-piece-translations.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ContentPieceTranslation } from './content-piece-translations.entity'; +import { ContentPieceTranslationService } from './content-piece-translations.service'; +import { ContentPieceTranslationResolver } from './content-piece-translations.resolver'; +import { ContentPieceTranslationController } from './content-piece-translations.controller'; +import { PubSub } from 'graphql-subscriptions'; + +@Module({ + imports: [TypeOrmModule.forFeature([ContentPieceTranslation])], + controllers: [ContentPieceTranslationController], + providers: [ + ContentPieceTranslationService, + ContentPieceTranslationResolver, + { + provide: PubSub, + useValue: new PubSub(), + }, + ], + exports: [ContentPieceTranslationService], +}) +export class ContentPieceTranslationsModule {} diff --git a/apps/backend/src/content-piece-translations/content-piece-translations.resolver.ts b/apps/backend/src/content-piece-translations/content-piece-translations.resolver.ts new file mode 100644 index 0000000..d7f8bfc --- /dev/null +++ b/apps/backend/src/content-piece-translations/content-piece-translations.resolver.ts @@ -0,0 +1,15 @@ +import { PubSub } from 'graphql-subscriptions'; +import { Resolver, Subscription } from '@nestjs/graphql'; +import { ContentPieceTranslation } from './content-piece-translations.entity'; + +@Resolver(() => ContentPieceTranslation) +export class ContentPieceTranslationResolver { + constructor(private readonly pubSub: PubSub) {} + + @Subscription(() => ContentPieceTranslation, { + name: 'contentPieceTranslationUpdated', + }) + contentPieceTranslationUpdated() { + return this.pubSub.asyncIterableIterator('contentPieceTranslationUpdated'); + } +} diff --git a/apps/backend/src/content-piece-translations/content-piece-translations.service.ts b/apps/backend/src/content-piece-translations/content-piece-translations.service.ts new file mode 100644 index 0000000..367966e --- /dev/null +++ b/apps/backend/src/content-piece-translations/content-piece-translations.service.ts @@ -0,0 +1,95 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ContentPieceTranslation } from './content-piece-translations.entity'; +import { CreateContentPieceTranslationDto } from './dto/create-content-piece-translations.dto'; +import { UpdateContentPieceTranslationDto } from './dto/update-content-piece-translations.dto'; +import { PubSub } from 'graphql-subscriptions'; + +@Injectable() +export class ContentPieceTranslationService { + constructor( + @InjectRepository(ContentPieceTranslation) + private readonly translationRepository: Repository, + private readonly pubSub: PubSub, + ) {} + + async create(createTranslationDto: CreateContentPieceTranslationDto): Promise { + const translation = this.translationRepository.create({ + ...createTranslationDto, + contentPiece: { id: createTranslationDto.contentPieceId }, + }); + const newTranslation = await this.translationRepository.save(translation); + + await this.pubSub.publish('contentPieceTranslationUpdated', { + contentPieceTranslationUpdated: { + ...newTranslation, + campaignId: createTranslationDto.campaignId, + contentPieceId: newTranslation.contentPiece.id, + _type: 'create', + }, + }); + return newTranslation; + } + + async findAll(contentPieceId: string | undefined = undefined): Promise { + if (!contentPieceId) { + return this.translationRepository.find({ relations: ['contentPiece'] }); + } + + return this.translationRepository.find({ + where: { + contentPiece: { + id: contentPieceId, + }, + }, + relations: ['contentPiece'], + }); + } + + async findOne(id: string): Promise { + const translation = await this.translationRepository.findOne({ + where: { id }, + relations: ['contentPiece', 'contentPiece.campaign'], + }); + if (!translation) { + throw new NotFoundException(`Translation with ID ${id} not found`); + } + return translation; + } + + async update(id: string, updateTranslationDto: UpdateContentPieceTranslationDto): Promise { + const translation = await this.findOne(id); + this.translationRepository.merge(translation, updateTranslationDto); + const updatedTranslation = await this.translationRepository.save(translation); + + await this.pubSub.publish('contentPieceTranslationUpdated', { + contentPieceTranslationUpdated: { + ...updatedTranslation, + campaignId: translation.contentPiece.campaign.id, + contentPieceId: translation.contentPiece.id, + _type: 'update', + }, + }); + return updatedTranslation; + } + + async remove(id: string): Promise { + const translation = await this.translationRepository.findOne({ where: { id } }); + if (!translation) { + throw new NotFoundException(`Translation with ID ${id} not found`); + } + await this.translationRepository.remove(translation); + + await this.pubSub.publish('contentPieceTranslationUpdated', { + contentPieceTranslationUpdated: { + ...translation, + id, + campaignId: translation.contentPiece.campaign.id, + contentPieceId: translation.contentPiece.id, + _type: 'remove', + }, + }); + return translation; + } +} diff --git a/apps/backend/src/content-piece-translations/dto/create-content-piece-translations.dto.ts b/apps/backend/src/content-piece-translations/dto/create-content-piece-translations.dto.ts new file mode 100644 index 0000000..0ee0bd2 --- /dev/null +++ b/apps/backend/src/content-piece-translations/dto/create-content-piece-translations.dto.ts @@ -0,0 +1,58 @@ +import { IsString, IsNotEmpty, IsBoolean } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { ModelProvider } from 'src/langchain/langchain.enum'; + +export class CreateContentPieceTranslationDto { + @ApiProperty({ + description: 'The ID of the campaign this translation belongs to', + example: '1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d', + }) + @IsString() + @IsNotEmpty() + campaignId: string; + + @ApiProperty({ + description: 'The ID of the content piece this translation belongs to', + example: '1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d', + }) + @IsString() + @IsNotEmpty() + contentPieceId: string; + + @ApiProperty({ + description: 'The model provider used for the translation', + example: ModelProvider.OpenAI, + }) + @IsString() + @IsNotEmpty() + modelProvider: ModelProvider; + + @ApiProperty({ description: 'The language code for the translation', example: 'es' }) + @IsString() + @IsNotEmpty() + languageCode: string; + + @ApiProperty({ description: 'The translated title', example: 'Hola Mundo' }) + @IsString() + @IsNotEmpty() + translatedTitle: string; + + @ApiProperty({ description: 'The translated description', example: 'Una breve descripcion.' }) + @IsString() + @IsNotEmpty() + translatedDescription: string; + + @ApiProperty({ + description: 'A flag to indicate if the translation was AI-generated', + example: true, + }) + @IsBoolean() + isAIGenerated: boolean; + + @ApiProperty({ + description: 'A flag to indicate if a human has edited the translation', + example: false, + }) + @IsBoolean() + isHumanEdited: boolean; +} diff --git a/apps/backend/src/content-piece-translations/dto/update-content-piece-translations.dto.ts b/apps/backend/src/content-piece-translations/dto/update-content-piece-translations.dto.ts new file mode 100644 index 0000000..47adcc3 --- /dev/null +++ b/apps/backend/src/content-piece-translations/dto/update-content-piece-translations.dto.ts @@ -0,0 +1,21 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateContentPieceTranslationDto } from './create-content-piece-translations.dto'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional, IsString, IsBoolean } from 'class-validator'; + +export class UpdateContentPieceTranslationDto extends PartialType(CreateContentPieceTranslationDto) { + @ApiProperty({ description: 'The translated title', required: false }) + @IsOptional() + @IsString() + translatedTitle?: string; + + @ApiProperty({ description: 'The translated description', required: false }) + @IsOptional() + @IsString() + translatedDescription?: string; + + @ApiProperty({ description: 'A flag to indicate if a human has edited the translation', required: false }) + @IsOptional() + @IsBoolean() + isHumanEdited?: boolean; +} diff --git a/apps/backend/src/content-pieces/content-piece.entity.ts b/apps/backend/src/content-pieces/content-piece.entity.ts new file mode 100644 index 0000000..25086b5 --- /dev/null +++ b/apps/backend/src/content-pieces/content-piece.entity.ts @@ -0,0 +1,60 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + OneToMany, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Field, ID, ObjectType } from '@nestjs/graphql'; +import { Campaign } from '../campaigns/campaign.entity'; +import { ContentPieceTranslation } from '../content-piece-translations/content-piece-translations.entity'; +import { ReviewState } from './review-state.enum'; + +@ObjectType() +@Entity() +export class ContentPiece { + @Field(() => ID) + @PrimaryGeneratedColumn('uuid') + id: string; + + @Field(() => ReviewState) + @Column({ + type: 'enum', + enum: ReviewState, + default: ReviewState.Draft, + }) + reviewState: ReviewState; + + @Field(() => String, { nullable: true }) + @Column({ type: 'jsonb', nullable: true }) + aiGeneratedDraft: object; + + @Field() + @Column() + sourceLanguage: string; + + @Field() + @CreateDateColumn() + createdAt: Date; + + @Field() + @UpdateDateColumn() + updatedAt: Date; + + @Field(() => Campaign) + @ManyToOne(() => Campaign, (campaign) => campaign.contentPieces) + campaign: Campaign; + + @Field(() => [ContentPieceTranslation], { nullable: false }) + @OneToMany(() => ContentPieceTranslation, (translation): ContentPiece => translation.contentPiece) + translations: ContentPieceTranslation[]; + + // For subscription purposes + @Field() + _type: string; + + @Field() + campaignId: string; +} diff --git a/apps/backend/src/content-pieces/content-pieces.controller.ts b/apps/backend/src/content-pieces/content-pieces.controller.ts new file mode 100644 index 0000000..6a1e2d0 --- /dev/null +++ b/apps/backend/src/content-pieces/content-pieces.controller.ts @@ -0,0 +1,125 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Patch, + Delete, + NotFoundException, + ParseUUIDPipe, + BadRequestException, + Query, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiParam } from '@nestjs/swagger'; +import { ContentPiecesService } from './content-pieces.service'; +import { ContentPiece } from './content-piece.entity'; +import { CreateContentPieceDto } from './dto/create-content-piece.dto'; +import { UpdateContentPieceDto } from './dto/update-content-piece.dto'; +import { GenerateContentDto } from './dto/generate-content.dto'; +import { ModelProvider } from 'src/langchain/langchain.enum'; +import { ReviewState } from './review-state.enum'; + +@ApiTags('content-pieces') +@Controller('content-pieces') +export class ContentPiecesController { + constructor(private readonly contentPiecesService: ContentPiecesService) {} + + @Post() + @ApiOperation({ summary: 'Create a new content piece' }) + async create(@Body() createContentPieceDto: CreateContentPieceDto): Promise { + return this.contentPiecesService.create(createContentPieceDto); + } + + @Get() + @ApiOperation({ summary: 'Get all content pieces' }) + async findAll(@Query('campaignId') campaignId: string): Promise { + return this.contentPiecesService.findAll(campaignId); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a content piece by ID' }) + @ApiParam({ name: 'id', description: 'The ID of the content piece' }) + async findOne(@Param('id', new ParseUUIDPipe()) id: string): Promise { + try { + return await this.contentPiecesService.findOne(id); + } catch (error) { + if (error instanceof NotFoundException) { + throw new NotFoundException(error.message); + } + throw error; + } + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a content piece by ID' }) + @ApiParam({ name: 'id', description: 'The ID of the content piece to update' }) + async update( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() updateContentPieceDto: UpdateContentPieceDto, + ): Promise { + return this.contentPiecesService.update(id, updateContentPieceDto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a content piece by ID' }) + @ApiParam({ name: 'id', description: 'The ID of the content piece to delete' }) + async remove(@Param('id', new ParseUUIDPipe()) id: string): Promise { + return this.contentPiecesService.remove(id); + } + + @Post('generate') + @ApiOperation({ summary: 'Generate content for a content piece' }) + async generateContent(@Body() generateContentDto: GenerateContentDto): Promise { + const { id, campaignId, locale, modelProvider } = generateContentDto; + let provider: ModelProvider; + + if (!['openai', 'anthropic'].includes(modelProvider)) { + throw new BadRequestException('modelProvider must be either "openai" or "anthropic"'); + } else { + provider = modelProvider as ModelProvider; + } + + try { + if (id) { + return await this.contentPiecesService.generateForExistingContent(id, locale, provider); + } else { + if (!campaignId) throw new BadRequestException('campaignId is required when id is not provided'); + + return await this.contentPiecesService.generateForNewContent(campaignId, locale, provider); + } + } catch (error) { + console.error('Error generating content:', error); + throw new BadRequestException(error || 'Error generating content'); + } + } + + // Approve or Reject content endpoints + @Post(':id/approve') + @ApiOperation({ summary: 'Approve a content piece by ID' }) + @ApiParam({ name: 'id', description: 'The ID of the content piece to approve' }) + async approveContent(@Param('id', new ParseUUIDPipe()) id: string): Promise { + return await this.contentPiecesService.update(id, { + reviewState: ReviewState.Approved, + }); + } + + @Post(':id/reject') + @ApiOperation({ summary: 'Reject a content piece by ID' }) + @ApiParam({ name: 'id', description: 'The ID of the content piece to reject' }) + async rejectContent(@Param('id', new ParseUUIDPipe()) id: string): Promise { + return await this.contentPiecesService.update(id, { + reviewState: ReviewState.Rejected, + }); + } + + // Update to 'Reviewed' state + @Post(':id/reviewed') + @ApiOperation({ summary: "Mark a content piece as 'Reviewed' by ID" }) + @ApiParam({ name: 'id', description: 'The ID of the content piece to mark as reviewed' }) + async markAsReviewed(@Param('id', new ParseUUIDPipe()) id: string): Promise { + return await this.contentPiecesService.update(id, { + reviewState: ReviewState.Reviewed, + }); + } +} diff --git a/apps/backend/src/content-pieces/content-pieces.module.ts b/apps/backend/src/content-pieces/content-pieces.module.ts new file mode 100644 index 0000000..534ec97 --- /dev/null +++ b/apps/backend/src/content-pieces/content-pieces.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ContentPiece } from './content-piece.entity'; +import { ContentPiecesService } from './content-pieces.service'; +import { ContentPiecesResolver } from './content-pieces.resolver'; +import { ContentPiecesController } from './content-pieces.controller'; +import { PubSub } from 'graphql-subscriptions'; +import { LangChainService } from 'src/langchain/langchain.service'; +import { ContentPieceTranslationsModule } from 'src/content-piece-translations/content-piece-translations.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([ContentPiece]), ContentPieceTranslationsModule], + controllers: [ContentPiecesController], + providers: [ + ContentPiecesService, + ContentPiecesResolver, + LangChainService, + { + provide: PubSub, + useValue: new PubSub(), + }, + ], + exports: [ContentPiecesService], +}) +export class ContentPiecesModule {} diff --git a/apps/backend/src/content-pieces/content-pieces.resolver.ts b/apps/backend/src/content-pieces/content-pieces.resolver.ts new file mode 100644 index 0000000..0e964c7 --- /dev/null +++ b/apps/backend/src/content-pieces/content-pieces.resolver.ts @@ -0,0 +1,25 @@ +import { PubSub } from 'graphql-subscriptions'; +import { Parent, ResolveField, Resolver, Subscription } from '@nestjs/graphql'; +import { ContentPiece } from './content-piece.entity'; +import { ContentPieceTranslation } from 'src/content-piece-translations/content-piece-translations.entity'; +import { ContentPieceTranslationService } from 'src/content-piece-translations/content-piece-translations.service'; + +@Resolver(() => ContentPiece) +export class ContentPiecesResolver { + constructor( + private readonly contentPieceTranslationsService: ContentPieceTranslationService, + private readonly pubSub: PubSub, + ) {} + + @Subscription(() => ContentPiece, { + name: 'contentPieceUpdated', + }) + contentPieceUpdated() { + return this.pubSub.asyncIterableIterator('contentPieceUpdated'); + } + + @ResolveField(() => [ContentPieceTranslation]) + async translations(@Parent() contentPiece: ContentPiece): Promise { + return await this.contentPieceTranslationsService.findAll(contentPiece.id); + } +} diff --git a/apps/backend/src/content-pieces/content-pieces.service.ts b/apps/backend/src/content-pieces/content-pieces.service.ts new file mode 100644 index 0000000..d86f4cd --- /dev/null +++ b/apps/backend/src/content-pieces/content-pieces.service.ts @@ -0,0 +1,182 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ContentPiece } from './content-piece.entity'; +import { CreateContentPieceDto } from './dto/create-content-piece.dto'; +import { UpdateContentPieceDto } from './dto/update-content-piece.dto'; +import { PubSub } from 'graphql-subscriptions'; +import { LangChainService } from 'src/langchain/langchain.service'; +import { ReviewState } from './review-state.enum'; +import { ContentPieceTranslationService } from 'src/content-piece-translations/content-piece-translations.service'; +import { ModelProvider } from 'src/langchain/langchain.enum'; + +@Injectable() +export class ContentPiecesService { + constructor( + @InjectRepository(ContentPiece) + private readonly contentPieceRepository: Repository, + private readonly translationsService: ContentPieceTranslationService, + private readonly langChainService: LangChainService, + private readonly pubSub: PubSub, + ) {} + + async create(createContentPieceDto: CreateContentPieceDto): Promise { + const entity = this.contentPieceRepository.create({ + sourceLanguage: createContentPieceDto.sourceLanguage, + campaign: { id: createContentPieceDto.campaignId }, + }); + const newContent = await this.contentPieceRepository.save(entity); + + await this.pubSub.publish('contentPieceUpdated', { + contentPieceUpdated: { + ...newContent, + campaignId: newContent.campaign.id, + _type: 'create', + }, + }); + return newContent; + } + + async findAll(campaignId: string | undefined = undefined): Promise { + if (campaignId) { + return await this.contentPieceRepository.find({ + where: { campaign: { id: campaignId } }, + relations: ['campaign', 'translations'], + }); + } + + return await this.contentPieceRepository.find({ + relations: ['campaign', 'translations'], + }); + } + + async findOne(id: string): Promise { + const contentPiece = await this.contentPieceRepository.findOne({ + where: { id }, + relations: ['campaign', 'translations'], + }); + if (!contentPiece) { + throw new NotFoundException(`ContentPiece with ID ${id} not found`); + } + return contentPiece; + } + + async update(id: string, updateContentPieceDto: UpdateContentPieceDto): Promise { + const contentPiece = await this.findOne(id); + this.contentPieceRepository.merge(contentPiece, updateContentPieceDto); + const updatedContentPiece = await this.contentPieceRepository.save(contentPiece); + + await this.pubSub.publish('contentPieceUpdated', { + contentPieceUpdated: { + ...updatedContentPiece, + campaignId: updatedContentPiece.campaign.id, + _type: 'update', + }, + }); + return updatedContentPiece; + } + + async remove(id: string): Promise { + const contentPiece = await this.findOne(id); + if (!contentPiece) { + throw new NotFoundException(`ContentPiece with ID ${id} not found`); + } + await this.contentPieceRepository.delete(id); + + await this.pubSub.publish('contentPieceUpdated', { + contentPieceUpdated: { + ...contentPiece, + id, + campaignId: contentPiece.campaign.id, + _type: 'remove', + }, + }); + return contentPiece; + } + + // generation info + async generateForExistingContent(id: string, locale: string, modelProvider: ModelProvider): Promise { + const contentPiece = await this.findOne(id); + + if (!contentPiece) { + throw new NotFoundException(`Content piece with ID ${id} not found`); + } + + const existingTranslationIndex = contentPiece.translations.findIndex((t) => t.languageCode === locale); + const existingTranslation = contentPiece.translations[existingTranslationIndex]; + + const topic = `${contentPiece.campaign.name} ${contentPiece.campaign.name}`; + if (existingTranslation) { + // Update existing translation + const generatedData = await this.langChainService.generateDraft(locale, topic, modelProvider); + existingTranslation.translatedTitle = generatedData.title; + existingTranslation.translatedDescription = generatedData.description; + existingTranslation.isAIGenerated = true; + existingTranslation.isHumanEdited = false; + + contentPiece.translations[existingTranslationIndex] = await this.translationsService.update( + existingTranslation.id, + existingTranslation, + ); + } else { + // Create new translation + if (!contentPiece.translations || contentPiece.translations.length === 0) { + contentPiece.translations = []; + + const generatedData = await this.langChainService.generateDraft(locale, topic, modelProvider); + contentPiece.translations.push( + await this.translationsService.create({ + modelProvider, + campaignId: contentPiece.campaign.id, + contentPieceId: contentPiece.id, + languageCode: locale, + translatedDescription: generatedData.description, + translatedTitle: generatedData.title, + isAIGenerated: true, + isHumanEdited: false, + }), + ); + } else { + // at this point we know there are existing translations, so we can use the first one as a base for translation + const baseTranslation = contentPiece.translations[0]; + const generatedData = await this.langChainService.translateContent( + locale, + baseTranslation.translatedTitle, + baseTranslation.translatedDescription, + modelProvider, + ); + contentPiece.translations.push( + await this.translationsService.create({ + modelProvider, + languageCode: locale, + translatedTitle: generatedData.title, + translatedDescription: generatedData.description, + contentPieceId: contentPiece.id, + campaignId: contentPiece.campaign.id, + isAIGenerated: true, + isHumanEdited: false, + }), + ); + } + } + contentPiece.reviewState = ReviewState.SuggestedByAI; + return this.update(id, contentPiece); + } + + /** + * Method to create from scratch a new content piece and generate content for it + * + * create new content piece with the campaignId and use generateForExistingContent to generate content + * @param campaignId + * @param locale + * @param modelProvider + * @returns + */ + async generateForNewContent(campaignId: string, locale: string, modelProvider: ModelProvider): Promise { + const newContentPiece = await this.create({ + sourceLanguage: locale, + campaignId, + }); + return await this.generateForExistingContent(newContentPiece.id, locale, modelProvider); + } +} diff --git a/apps/backend/src/content-pieces/dto/create-content-piece.dto.ts b/apps/backend/src/content-pieces/dto/create-content-piece.dto.ts new file mode 100644 index 0000000..a4bd994 --- /dev/null +++ b/apps/backend/src/content-pieces/dto/create-content-piece.dto.ts @@ -0,0 +1,20 @@ +import { IsString, IsNotEmpty } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateContentPieceDto { + @ApiProperty({ + description: 'The source language of the content piece', + example: 'en', + }) + @IsString() + @IsNotEmpty() + sourceLanguage: string; + + @ApiProperty({ + description: 'The ID of the campaign this content piece belongs to', + example: '1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d', + }) + @IsString() + @IsNotEmpty() + campaignId: string; +} diff --git a/apps/backend/src/content-pieces/dto/generate-content.dto.ts b/apps/backend/src/content-pieces/dto/generate-content.dto.ts new file mode 100644 index 0000000..ebe84ae --- /dev/null +++ b/apps/backend/src/content-pieces/dto/generate-content.dto.ts @@ -0,0 +1,31 @@ +import { IsOptional, IsString, IsIn } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class GenerateContentDto { + @ApiProperty({ + description: 'The ID of the content piece to update. If not provided, a new content piece will be created.', + required: false, + }) + @IsOptional() + @IsString() + id?: string; + + @ApiProperty({ + description: 'The ID of the campaign to associate with the new content piece. Required if id is not provided.', + required: false, + }) + @IsOptional() + @IsString() + campaignId?: string; + + @ApiProperty({ description: 'The locale for the content piece', example: 'en-EN' }) + @IsString() + locale: string; + + @ApiProperty({ description: 'The model provider to use', example: 'openai', enum: ['openai', 'anthropic'] }) + @IsString() + @IsIn(['openai', 'anthropic'], { + message: 'modelProvider must be either "openai" or "anthropic"', + }) + modelProvider: 'openai' | 'anthropic'; +} diff --git a/apps/backend/src/content-pieces/dto/update-content-piece.dto.ts b/apps/backend/src/content-pieces/dto/update-content-piece.dto.ts new file mode 100644 index 0000000..648182b --- /dev/null +++ b/apps/backend/src/content-pieces/dto/update-content-piece.dto.ts @@ -0,0 +1,17 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateContentPieceDto } from './create-content-piece.dto'; +import { IsOptional, IsEnum } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { ReviewState } from '../review-state.enum'; + +export class UpdateContentPieceDto extends PartialType(CreateContentPieceDto) { + @ApiProperty({ + description: 'The review state of the content piece', + example: 'Reviewed', + enum: ReviewState, + required: false, + }) + @IsOptional() + @IsEnum(ReviewState) + reviewState?: ReviewState; +} diff --git a/apps/backend/src/content-pieces/review-state.enum.ts b/apps/backend/src/content-pieces/review-state.enum.ts new file mode 100644 index 0000000..ed2ecac --- /dev/null +++ b/apps/backend/src/content-pieces/review-state.enum.ts @@ -0,0 +1,14 @@ +import { registerEnumType } from '@nestjs/graphql'; + +export enum ReviewState { + Draft = 'Draft', + SuggestedByAI = 'Suggested by AI', + Reviewed = 'Reviewed', + Approved = 'Approved', + Rejected = 'Rejected', +} + +registerEnumType(ReviewState, { + name: 'ReviewState', + description: 'The review state of a content piece.', +}); diff --git a/apps/backend/src/langchain/langchain.enum.ts b/apps/backend/src/langchain/langchain.enum.ts new file mode 100644 index 0000000..0f27355 --- /dev/null +++ b/apps/backend/src/langchain/langchain.enum.ts @@ -0,0 +1,11 @@ +import { registerEnumType } from '@nestjs/graphql'; + +export enum ModelProvider { + OpenAI = 'openai', + Anthropic = 'anthropic', +} + +registerEnumType(ModelProvider, { + name: 'ModelProvider', + description: 'The model provider for AI content generation.', +}); diff --git a/apps/backend/src/langchain/langchain.module.ts b/apps/backend/src/langchain/langchain.module.ts new file mode 100644 index 0000000..8fa54c1 --- /dev/null +++ b/apps/backend/src/langchain/langchain.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { LangChainService } from './langchain.service'; + +@Module({ + providers: [LangChainService], + exports: [LangChainService], +}) +export class LangChainModule {} diff --git a/apps/backend/src/langchain/langchain.service.ts b/apps/backend/src/langchain/langchain.service.ts new file mode 100644 index 0000000..b3d53d3 --- /dev/null +++ b/apps/backend/src/langchain/langchain.service.ts @@ -0,0 +1,116 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { ChatOpenAI } from '@langchain/openai'; +import { ChatAnthropic } from '@langchain/anthropic'; +import { PromptTemplate } from '@langchain/core/prompts'; +import { BaseLanguageModel } from '@langchain/core/language_models/base'; +import { JsonOutputParser } from '@langchain/core/output_parsers'; +import { ModelProvider } from './langchain.enum'; + +@Injectable() +export class LangChainService { + private readonly openAILLM: ChatOpenAI; + private readonly anthropicLLM: ChatAnthropic; + + constructor() { + this.openAILLM = new ChatOpenAI({ + openAIApiKey: process.env.OPENAI_API_KEY, + temperature: 0.9, + modelName: 'gpt-4o-mini', + maxRetries: 1, + maxTokens: 500, + }); + this.anthropicLLM = new ChatAnthropic({ + anthropicApiKey: process.env.ANTHROPIC_API_KEY, + temperature: 0.7, + modelName: 'claude-sonnet-4-20250514', + maxRetries: 1, + maxTokens: 500, + }); + } + + private getModel(provider: ModelProvider): BaseLanguageModel { + switch (provider) { + case ModelProvider.OpenAI: + return this.openAILLM; + case ModelProvider.Anthropic: + return this.anthropicLLM; + default: + throw new BadRequestException('Invalid AI model provider'); + } + } + + /** + * Generate a draft for a specific topic and language. The draft includes a title and description. + * + * @param sourceLanguage The language to generate content in. + * @param topic The topic to generate content about. + * @param modelProvider The model provider to use for generation. + * @return The generated content. + */ + async generateDraft(sourceLanguage: string, topic: string, modelProvider: ModelProvider) { + const llm = this.getModel(modelProvider); + + const promptTemplate = PromptTemplate.fromTemplate( + `Generate a short, engaging marketing headline and description. You can be inspired by {topic} in {sourceLanguage}. + Return the result as a JSON object with "title" and "description" keys. + `, + ); + + const chain = promptTemplate.pipe(llm).pipe(new JsonOutputParser()); + + const result = await chain.invoke({ + topic, + sourceLanguage, + }); + + try { + const _parsed = result; + if (!_parsed || typeof _parsed !== 'object') { + throw new Error('Invalid response format'); + } + const parsed = _parsed as { title: string; description: string }; + + if (typeof parsed.title === 'string' && typeof parsed.description === 'string') { + return parsed; + } + throw new Error('Invalid response format'); + } catch { + throw new BadRequestException('Failed to parse AI response as JSON'); + } + } + + // has to receive locale, baseTranslation.translatedTitle, baseTranslation.translatedDescription, modelProvider, + // and return { title: string; description: string } + async translateContent(locale: string, title: string, description: string, modelProvider: ModelProvider) { + const llm = this.getModel(modelProvider); + + const translatePrompt = PromptTemplate.fromTemplate(`Translate the following text into {locale}. + title: "{title}". description: "{description}". + Return the result as a JSON object with "title" and "description" keys. + `); + + // Use LCEL again + const translateChain = translatePrompt.pipe(llm).pipe(new JsonOutputParser()); + + const result = await translateChain.invoke({ + title, + description, + locale, + }); + + try { + const _parsed = result; + if (!_parsed || typeof _parsed !== 'object') { + throw new Error('Invalid response format'); + } + const parsed = _parsed as { title: string; description: string }; + + if (typeof parsed.title === 'string' && typeof parsed.description === 'string') { + return parsed; + } + throw new Error('Invalid response format'); + } catch { + throw new BadRequestException('Failed to parse AI response as JSON'); + } + } +} diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts new file mode 100644 index 0000000..d1a669c --- /dev/null +++ b/apps/backend/src/main.ts @@ -0,0 +1,32 @@ +import { NestFactory } from '@nestjs/core'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + const config = new DocumentBuilder() + .setTitle('ACME GLOBAL MEDIA API') + .setDescription('API documentation for the AI content workflow application') + .setVersion('1.0') + .addTag('campaigns') + .addTag('content-pieces') + .addTag('translations') + .build(); + + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api/docs', app, document); + + app.enableCors({ + origin: '*', + methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS', + preflightContinue: false, + optionsSuccessStatus: 204, + }); + + await app.listen(process.env.PORT ?? 3000); +} + +bootstrap().catch((err) => { + console.error('Error during bootstrap:', err); +}); diff --git a/apps/backend/test/app.e2e-spec.ts b/apps/backend/test/app.e2e-spec.ts new file mode 100644 index 0000000..ce6ab9f --- /dev/null +++ b/apps/backend/test/app.e2e-spec.ts @@ -0,0 +1,23 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +// import * as request from 'supertest'; +import { App } from 'supertest/types'; +import { AppModule } from './../src/app.module'; + +describe('AppController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it('/ (GET)', () => { + // return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!'); + return true; + }); +}); diff --git a/apps/backend/test/jest-e2e.json b/apps/backend/test/jest-e2e.json new file mode 100644 index 0000000..e9d912f --- /dev/null +++ b/apps/backend/test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/apps/backend/tsconfig.build.json b/apps/backend/tsconfig.build.json new file mode 100644 index 0000000..09887d3 --- /dev/null +++ b/apps/backend/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "dist", "*coverage", "test", "logs", "scripts", "tools", "**/*spec.ts"] +} diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json new file mode 100644 index 0000000..d9aad43 --- /dev/null +++ b/apps/backend/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "useDefineForClassFields": false, + "target": "ES2019", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "skipLibCheck": true, + "allowJs": false, + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true + }, + "include": ["src/**/*", "test/**/*", ".prettierrc.js"], + "exclude": ["node_modules", "dist", "*coverage", "logs", "scripts"] +} diff --git a/apps/frontend/.dockerignore b/apps/frontend/.dockerignore new file mode 100644 index 0000000..ae1db91 --- /dev/null +++ b/apps/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.git +build +dist +tools diff --git a/apps/frontend/.editorconfig b/apps/frontend/.editorconfig new file mode 100644 index 0000000..f1cc3ad --- /dev/null +++ b/apps/frontend/.editorconfig @@ -0,0 +1,15 @@ +# http://editorconfig.org + +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +insert_final_newline = false +trim_trailing_whitespace = false diff --git a/apps/frontend/.env.example b/apps/frontend/.env.example new file mode 100644 index 0000000..e69de29 diff --git a/apps/frontend/.github/copilot-instructions.md b/apps/frontend/.github/copilot-instructions.md new file mode 100644 index 0000000..c907a85 --- /dev/null +++ b/apps/frontend/.github/copilot-instructions.md @@ -0,0 +1,71 @@ +# GitHub Copilot Instructions for React Vite Project + +## Core Guidelines + +1. **Check Documentation First** + - Always reference the `docs/` folder for technology-specific guides and patterns + - Follow the README.md for setup, testing, and development instructions + - Consult official documentation when uncertain + +2. **Code Quality** + - Run `npm run format` to format code automatically + - Run `npm run lint:fix` to fix linting issues + - Keep code clean, typed, and well-structured + - Use TypeScript properly and avoid `any` types + +3. **Project Structure** + - Follow the established Feature-Based Architecture + - Check `docs/PROJECT_STRUCTURE.md` for organization patterns + - Keep features self-contained and modular + - Place components in appropriate feature directories + +4. **Development Workflow** + - Test changes using the commands in README.md + - Follow the project's established patterns and conventions + - Reference existing code for consistency + - Use Vite's development server and HMR effectively + +5. **Best Practices** + - Write clean, readable, and maintainable code + - Use proper error handling and validation + - Follow React and TypeScript best practices + - Implement proper loading and error states + - Optimize for performance with React.memo and useMemo when needed + +## When Suggesting Code + +1. **Component Creation** + - Check if similar components exist in the codebase + - Look for relevant guides in the `docs/` folder + - Follow the established component patterns + - Use TypeScript interfaces for props + - Include proper error boundaries when appropriate + +2. **State Management** + - Reference `docs/` for state management patterns used in the project + - Use React hooks appropriately (useState, useEffect, useContext) + - Follow established patterns for global state if configured + +3. **Styling** + - Check `docs/` for styling approach used in the project + - Follow established CSS/styling patterns + - Use consistent naming conventions + - Implement responsive design patterns + +4. **Testing** + - Suggest appropriate tests if testing is configured + - Follow testing patterns established in the project + - Reference testing guides in `docs/` folder + +5. **Performance** + - Reference `docs/PERFORMANCE.md` for optimization guidelines + - Suggest lazy loading for route components + - Recommend proper bundle splitting techniques + +## React Vite Specific + +- Leverage Vite's fast development server and build optimizations +- Use ES modules and modern JavaScript features +- Take advantage of Vite's plugin ecosystem +- Follow Vite's file naming conventions for assets +- Use dynamic imports for code splitting diff --git a/apps/frontend/.gitignore b/apps/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/apps/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/frontend/.prettierrc.js b/apps/frontend/.prettierrc.js new file mode 100644 index 0000000..a2766c1 --- /dev/null +++ b/apps/frontend/.prettierrc.js @@ -0,0 +1,7 @@ +export default { + semi: true, + trailingComma: 'all', + singleQuote: true, + printWidth: 120, + tabWidth: 2, +}; diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile new file mode 100644 index 0000000..c498b00 --- /dev/null +++ b/apps/frontend/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ + +RUN npm install + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev"] diff --git a/apps/frontend/README.md b/apps/frontend/README.md new file mode 100644 index 0000000..88a8952 --- /dev/null +++ b/apps/frontend/README.md @@ -0,0 +1,69 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + ...tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + ...tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + ...tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]); +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x'; +import reactDom from 'eslint-plugin-react-dom'; + +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]); +``` diff --git a/apps/frontend/components.json b/apps/frontend/components.json new file mode 100644 index 0000000..2b0833f --- /dev/null +++ b/apps/frontend/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/apps/frontend/docs/COMPONENTS_AND_STYLING.md b/apps/frontend/docs/COMPONENTS_AND_STYLING.md new file mode 100644 index 0000000..983fe78 --- /dev/null +++ b/apps/frontend/docs/COMPONENTS_AND_STYLING.md @@ -0,0 +1,89 @@ +# đŸ§± Components And Styling + +## Components Best Practices + +### Colocate things as close as possible to where it's being used + +Keep components, functions, styles, state, etc. as close as possible to the component where it's being used. This will not only make your codebase more readable and easier to understand but it will also improve your application performance since it will reduce redundant re-renders on state updates. + +### Avoid large components with nested rendering functions + +Do not add multiple rendering functions inside your application, this gets out of control pretty quickly. What you should do instead is if there is a piece of UI that can be considered as a unit, is to extract it in a separate component. + +```javascript +// this is very difficult to maintain as soon as the component starts growing +function Component() { + function renderItems() { + return
    ...
; + } + return
{renderItems()}
; +} + +// extract it in a separate component +function Items() { + return
    ...
; +} + +function Component() { + return ( +
+ +
+ ); +} +``` + +### Stay consistent + +### Limit the number of props a component is accepting as input + +If your component is accepting too many props you might consider splitting it into multiple components or use the composition technique via children or slots. + +### Abstract shared components into a component library + +For larger projects, it is a good idea to build abstractions around all the shared components. It makes the application more consistent and easier to maintain. Identify repetitions before creating the components to avoid wrong abstractions. + +It is a good idea to wrap 3rd party components as well in order to adapt them to the application's needs. It might be easier to make the underlying changes in the future without affecting the application's functionality. + +## Component libraries + +Every project requires some UI components such as modals, tabs, sidebars, menus, etc. Instead of building those from scratch, you might want to use some of the existing, battle-tested component libraries. + +### Fully featured component libraries + +These component libraries come with their components fully styled. + +- [Chakra UI](https://chakra-ui.com/) - great library with probably the best developer experience, allows very fast prototyping with decent design defaults. Plenty of components that are very customizable and flexible with accessibility already configured out of the box. + +- [AntD](https://ant.design/) - another great component library that has a lot of different components. Best suitable for creating admin dashboards. However, it might be a bit difficult to change the styles in order to adapt them to a custom design. + +- [MUI](https://mui.com/) - the most popular component library for React. Has a lot of different components. Can be used as a styled solution by implementing Material Design or as unstyled headless component library. + +### Headless component libraries + +These component libraries come with their components unstyled. If you have a specific design system to implement, it might be easier and better solution to go with headless components that come unstyled than to adapt a styled components library such as Material UI to your needs. Some good options are: + +- [Reakit](https://reakit.io/) +- [Headless UI](https://headlessui.dev/) +- [Radix UI](https://www.radix-ui.com/) +- [react-aria](https://react-spectrum.adobe.com/react-aria/) + +## Styling Solutions + +There are multiple ways to style a react application. Some good options are: + +- [tailwind](https://tailwindcss.com/) +- [styled-components](https://styled-components.com/) +- [emotion](https://emotion.sh/docs/introduction) +- [stitches](https://stitches.dev/) +- [vanilla-extract](https://github.com/seek-oss/vanilla-extract) +- [CSS modules](https://github.com/css-modules/css-modules) +- [linaria](https://github.com/callstack/linaria) + +## Good combinations + +Some good combinations of component library + styling + +- [Chakra UI](https://chakra-ui.com/) + [emotion](https://emotion.sh/docs/introduction) - The best choice for most applications +- [Headless UI](https://headlessui.dev/) + [tailwind](https://tailwindcss.com/) +- [Radix UI](https://www.radix-ui.com/) + [stitches](https://stitches.dev/) diff --git a/apps/frontend/docs/PERFORMANCE.md b/apps/frontend/docs/PERFORMANCE.md new file mode 100644 index 0000000..94e5e4e --- /dev/null +++ b/apps/frontend/docs/PERFORMANCE.md @@ -0,0 +1,43 @@ +# 🚄 Performance + +## Code Splitting + +Code splitting is a technique of splitting production JavaScript into smaller files, thus allowing the application to be only partially downloaded. Any unused code will not be downloaded until it is required by the application. + +Most of the time code splitting should be done on the routes level, but can also be used for other lazy loaded parts of application. + +Do not code split everything as it might even worsen your application's performance. + +## Component and state optimizations + +- Do not put everything in a single state. That might trigger unnecessary re-renders. Instead split the global state into multiple stores according to where it is being used. + +- Keep the state as close as possible to where it is being used. This will prevent re-rendering components that do not depend on the updated state. + +- If you have a piece of state that is initialized by an expensive computation, use the state initializer function instead of executing it directly because the expensive function will be run only once as it is supposed to. e.g: + +```javascript +import { useState } from 'react'; + +// instead of this which would be executed on every re-render: +const [state, setState] = useState(myExpensiveFn()); + +// prefer this which is executed only once: +const [state, setState] = useState(() => myExpensiveFn()); +``` + +- If you develop an application that requires the state to track many elements at once, you might consider state management libraries with atomic updates such as [recoil](https://recoiljs.org/) or [jotai](https://jotai.pmnd.rs/). + +- If your application is expected to have frequent updates that might affect performance, consider switching from runtime styling solutions ([Chakra UI](https://chakra-ui.com/), [emotion](https://emotion.sh/docs/introduction), [styled-components](https://styled-components.com/) that generate styles during runtime) to zero runtime styling solutions ([tailwind](https://tailwindcss.com/), [linaria](https://github.com/callstack/linaria), [vanilla-extract](https://github.com/seek-oss/vanilla-extract), [CSS modules](https://github.com/css-modules/css-modules) which generate styles during build time). + +## Image optimizations + +Consider lazy loading images that are not in the viewport. + +Use modern image formats such as WEBP for faster image loading. + +Use `srcset` to load the most optimal image for the clients screen size. + +## Web vitals + +Since Google started taking web vitals in account when indexing websites, you should keep an eye on web vitals scores from [Lighthouse](https://web.dev/measure/) and [Pagespeed Insights](https://pagespeed.web.dev/). diff --git a/apps/frontend/docs/PROJECT_CONFIGURATION.md b/apps/frontend/docs/PROJECT_CONFIGURATION.md new file mode 100644 index 0000000..a34342b --- /dev/null +++ b/apps/frontend/docs/PROJECT_CONFIGURATION.md @@ -0,0 +1,55 @@ +# ⚙ Project Configuration + +The application has been bootstrapped using [create-awesome-node-app](https://www.npmjs.com/package/create-awesome-node-app) for simplicity reasons. It allows us to create applications quickly without dealing with a complex tooling setup such as bundling, transpiling etc. + +You should always configure and use the following tools: + +## ESLint + +ESLint is a linting tool for JavaScript. By providing specific configuration defined in the`eslint.config.mjs` file it prevents developers from making silly mistakes in their code and enforces consistency in the codebase. + +[ESLint Configuration](../eslint.config.mjs) + +## Prettier + +This is a great tool for formatting code. It enforces a consistent code style across your entire codebase. By utilizing the "format on save" feature in your IDE you can automatically format the code based on the configuration provided in the `.prettierrc` file. It will also give you good feedback when something is wrong with the code. If the auto-format doesn't work, something is wrong with the code. + +[Prettier Configuration](../.prettierrc.js) + +## TypeScript + +ESLint is great for catching some of the bugs related to the language, but since JavaScript is a dynamic language ESLint cannot check data that run through the applications, which can lead to bugs, especially on larger projects. That is why TypeScript should be used. It is very useful during large refactors because it reports any issues you might miss otherwise. When refactoring, change the type declaration first, then fix all the TypeScript errors throughout the project and you are done. One thing you should keep in mind is that TypeScript does not protect your application from failing during runtime, it only does type checking during build time, but it increases development confidence drastically anyways. Here is a [great resource on using TypeScript with React](https://react-typescript-cheatsheet.netlify.app/). + +## Husky + +Husky is a tool for executing git hooks. Use Husky to run your code validations before every commit, thus making sure the code is in the best shape possible at any point of time and no faulty commits get into the repo. It can run linting, code formatting and type checking, etc. before it allows pushing the code. You can check how to configure it [here](https://typicode.github.io/husky/#/?id=usage). + +## Absolute imports + +Absolute imports should always be configured and used because it makes it easier to move files around and avoid messy import paths such as `../../../Component`. Wherever you move the file, all the imports will remain intact. Here is how to configure it: + +For JavaScript (`jsconfig.json`) projects: + +```json +"compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +``` + +For TypeScript (`tsconfig.json`) projects: + +```json +"compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +``` + +[Paths Configuration](../tsconfig.json) + +It is also possible to define multiple paths for various folders(such as `@/components`, `@/hooks`, etc.), but using `@/*` works very well because it is short enough so there is no need to configure multiple paths and it differs from other dependency modules so there is no confusion in what comes from `node_modules` and what is our source folder. That means that anything in the `src` folder can be accessed via `@`, e.g some file that lives in `src/components/MyComponent` can be accessed using `@/components/MyComponents`. diff --git a/apps/frontend/docs/PROJECT_STRUCTURE.md b/apps/frontend/docs/PROJECT_STRUCTURE.md new file mode 100644 index 0000000..e9d0d90 --- /dev/null +++ b/apps/frontend/docs/PROJECT_STRUCTURE.md @@ -0,0 +1,126 @@ +# đŸ—„ïž Project Structure + +This project follows a Feature-Based Architecture, inspired by the principles of Screaming Architecture and Domain-Driven Design (DDD). The main goal is to organize code around business capabilities rather than technical concerns, making the codebase more maintainable and scalable. + +## Architecture Overview + +The codebase is organized into two main categories: + +1. **Shared Infrastructure** (`src/` root level) +2. **Feature Modules** (`src/features/`) + +### Shared Infrastructure + +The root level of `src/` contains shared resources used across multiple features: + +```sh +src +| ++-- components # shared components used across the entire application +| ++-- features # feature-based modules (MOST IMPORTANT!) +| ++-- hooks # shared hooks used across the entire application +| ++-- layouts # shared layouts used across the entire application +| ++-- libs # shared libs used across the entire application +| ++-- pages # shared pages used across the entire application +| ++-- providers # all of the application providers +| ++-- routes # all of the application routes +| ++-- services # shared services used across the entire application +| ++-- store # global state stores +| ++-- theme # shared theme used across the entire application +| ++-- utils # shared helpers used across the entire application +``` + +### Feature Modules + +Each feature is a self-contained module that encapsulates all the code related to a specific business capability. This follows the principles of: + +- **High Cohesion**: All related code is kept together +- **Low Coupling**: Features interact through well-defined interfaces +- **Domain-Driven Design**: Code organization reflects business capabilities +- **Screaming Architecture**: The structure "screams" the intent of the application + +A feature module structure: + +```sh +src/features/awesome-feature +| ++-- assets # assets used only in this feature +| ++-- components # components used only in this feature +| ++-- hooks # hooks used only in this feature +| ++-- providers # providers used only in this feature +| ++-- services # services used only in this feature +| ++-- store # stores used only in this feature +| ++-- theme # theme used only in this feature +| ++-- utils # helpers used only in this feature +| ++-- index.ts # public API of the feature +``` + +## Key Principles + +1. **Feature Encapsulation** + - Each feature is a self-contained module + - Features should not depend on the internal structure of other features + - All feature exports should go through the `index.ts` file + +2. **Public API** + - Features expose their functionality through a public API (`index.ts`) + - Other parts of the application should only import from the feature's root: + + ```typescript + // ✅ Good + import { AwesomeComponent } from '@/features/awesome-feature'; + + // ❌ Bad + import { AwesomeComponent } from '@/features/awesome-feature/components/AwesomeComponent'; + ``` + +3. **Import Rules** + To enforce these principles, you can add the following ESLint rule: + + ```js + { + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: ['@/features/*/*'], + }, + ], + } + } + ``` + +## Benefits + +1. **Maintainability**: Changes to a feature are isolated and don't affect other parts of the application +2. **Scalability**: New features can be added without modifying existing code +3. **Clarity**: The codebase structure clearly communicates the application's business capabilities +4. **Reusability**: Features can be reused across different parts of the application +5. **Testability**: Features can be tested in isolation + +## Best Practices + +1. Keep features focused on a single business capability +2. Minimize dependencies between features +3. Use the shared infrastructure for common functionality +4. Document the public API of each feature +5. Keep feature-specific code within the feature module diff --git a/apps/frontend/docs/README.md b/apps/frontend/docs/README.md new file mode 100644 index 0000000..796b82f --- /dev/null +++ b/apps/frontend/docs/README.md @@ -0,0 +1,11 @@ +# Extra Documentation + +This folder contains extra documentation for the project such us project structure, configuration, etc. + +## Resources + +- [Project Structure](./PROJECT_STRUCTURE.md) +- [Project Configuration](./PROJECT_CONFIGURATION.md) +- [Components and Styling](./COMPONENTS_AND_STYLING.md) +- [Performance recommendations](./PERFORMANCE.md) +- [State Management recommendations](./STATE_MANAGEMENT.md) diff --git a/apps/frontend/docs/STATE_MANAGEMENT.md b/apps/frontend/docs/STATE_MANAGEMENT.md new file mode 100644 index 0000000..f73580c --- /dev/null +++ b/apps/frontend/docs/STATE_MANAGEMENT.md @@ -0,0 +1,40 @@ +# đŸ—ƒïž State Management + +There is no need to keep all of your state in a single centralized store. There are different needs for different types of state that can be split into several types: + +## Component State + +This is the state that only a component needs, and it is not meant to be shared anywhere else. But you can pass it as prop to children components if needed. Most of the time, you want to start from here and lift the state up if needed elsewhere. For this type of state, you will usually need: + +- [useState](https://reactjs.org/docs/hooks-reference.html#usestate) - for simpler states that are independent +- [useReducer](https://reactjs.org/docs/hooks-reference.html#usereducer) - for more complex states where on a single action you want to update several pieces of state + +## Application State + +This is the state that controls interactive parts of an application. Opening modals, notifications, changing color mode, etc. For best performance and maintainability, keep the state as close as possible to the components that are using it. Don't make everything global out of the box. + +Our recommendation is to use any of the following state management libraries: + +- [jotai](https://github.com/pmndrs/jotai) +- [recoil](https://recoiljs.org/) +- [zustand](https://github.com/pmndrs/zustand) + +## Server Cache State + +This is the state that comes from the server which is being cached on the client for further usage. It is possible to store remote data inside a state management store such as redux, but there are better solutions for that. + +Our recommendation is: + +- [react-query](https://react-query.tanstack.com/) + +## Form State + +This is a state that tracks users inputs in a form. + +Forms in React can be [controlled](https://reactjs.org/docs/forms.html#controlled-components) and [uncontrolled](https://reactjs.org/docs/uncontrolled-components.html). + +Depending on the application needs, they might be pretty complex with many different fields which require validation. + +Although it is possible to build any form using only React, there are pretty good solutions out there that help with handling forms such as: + +- [React Hook Form](https://react-hook-form.com/) diff --git a/apps/frontend/eslint.config.mjs b/apps/frontend/eslint.config.mjs new file mode 100644 index 0000000..9f966cb --- /dev/null +++ b/apps/frontend/eslint.config.mjs @@ -0,0 +1,60 @@ +import globals from 'globals'; +import pluginJs from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; +import jsxA11yPlugin from 'eslint-plugin-jsx-a11y'; +import reactX from 'eslint-plugin-react-x'; +import reactDom from 'eslint-plugin-react-dom'; + +export default [ + { + files: ['**/*.{js,mjs,cjs,ts,tsx}'], + }, + { + ignores: [ + 'node_modules', + 'coverage', + 'dist', + 'dev-dist', + 'public', + '__mocks__', + 'src/theme', + 'tools', + '*.d.ts', + 'postcss.config.js', + ], + }, + { + languageOptions: { + globals: { + ...globals.browser, + ...globals.commonjs, + ...globals.node, + ...globals.es2020, + }, + parserOptions: { + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + pluginJs.configs.recommended, + ...tseslint.configs.recommended, + reactX.configs['recommended-typescript'], + reactDom.configs.recommended, + eslintPluginPrettierRecommended, + jsxA11yPlugin.flatConfigs.recommended, + { + rules: { + 'react/prop-types': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + // Prevent direct imports from feature subdirectories + 'no-restricted-imports': [ + 'error', + { + patterns: ['@/features/*/*'], + }, + ], + }, + }, +]; diff --git a/apps/frontend/index.html b/apps/frontend/index.html new file mode 100644 index 0000000..301966d --- /dev/null +++ b/apps/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + AI Content Workflow + + +
+ + + diff --git a/apps/frontend/package.json b/apps/frontend/package.json new file mode 100644 index 0000000..6c77dea --- /dev/null +++ b/apps/frontend/package.json @@ -0,0 +1,54 @@ +{ + "name": "frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@apollo/client": "^4.0.4", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tooltip": "^1.2.8", + "@tailwindcss/vite": "^4.1.13", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "graphql": "^16.11.0", + "graphql-ws": "^6.0.6", + "lucide": "^0.542.0", + "lucide-react": "^0.542.0", + "next-themes": "^0.4.6", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-router-dom": "^7.8.2", + "sonner": "^2.0.7", + "subscriptions-transport-ws": "^0.11.0", + "tailwind-merge": "^3.3.1", + "tailwindcss": "^4.1.13" + }, + "devDependencies": { + "@eslint/js": "^9.33.0", + "@types/node": "^22.18.1", + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^5.0.0", + "eslint": "^9.33.0", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-prettier": "^5.5.4", + "eslint-plugin-react-dom": "^1.53.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "eslint-plugin-react-x": "^1.53.0", + "globals": "^16.3.0", + "tw-animate-css": "^1.3.8", + "typescript": "~5.8.3", + "typescript-eslint": "^8.39.1", + "vite": "^7.1.2" + } +} diff --git a/apps/frontend/public/favicon.png b/apps/frontend/public/favicon.png new file mode 100644 index 0000000..e877654 Binary files /dev/null and b/apps/frontend/public/favicon.png differ diff --git a/apps/frontend/public/vite.svg b/apps/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/apps/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx new file mode 100644 index 0000000..b0d34b9 --- /dev/null +++ b/apps/frontend/src/App.tsx @@ -0,0 +1,39 @@ +import { Toaster } from '@/components/ui/sonner'; +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import { CampaignProvider } from '@/context/CampaignContext'; +import { GenerationConfigProvider } from './context/GenerationConfigContext'; +import CampaignIndex from '@/pages/CampaignIndex'; +import ContentIndex from '@/pages/ContentIndex'; +import NotFound from '@/pages/NotFound'; +import CampaignCreate from './pages/CampaignCreate'; + +function App() { + return ( + + + +
+ + {/* Campaign Index Page */} + } /> + } /> + + {/* Campaign Create Page */} + } /> + + {/* Content Index Page for a specific campaign */} + } /> + + {/* 404 Page */} + } /> + } /> + + +
+
+
+
+ ); +} + +export default App; diff --git a/apps/frontend/src/components/CampaignTable/CampaignRow.tsx b/apps/frontend/src/components/CampaignTable/CampaignRow.tsx new file mode 100644 index 0000000..a59b2a0 --- /dev/null +++ b/apps/frontend/src/components/CampaignTable/CampaignRow.tsx @@ -0,0 +1,29 @@ +import { TableRow, TableCell } from '@/components/ui/table'; +import { Table2 } from 'lucide-react'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; +import type { Campaign } from '@/lib/types'; +import { Link } from 'react-router-dom'; + +const CampaignRow = ({ campaign }: { campaign: Campaign }) => { + return ( + + {campaign.name} + {new Date(Date.parse(campaign.createdAt)).toLocaleString()} + {new Date(Date.parse(campaign.updatedAt)).toLocaleString()} + + + + + + + + View contents + + + + ); +}; + +export default CampaignRow; diff --git a/apps/frontend/src/components/CampaignTable/CampaignTable.tsx b/apps/frontend/src/components/CampaignTable/CampaignTable.tsx new file mode 100644 index 0000000..7f4776f --- /dev/null +++ b/apps/frontend/src/components/CampaignTable/CampaignTable.tsx @@ -0,0 +1,29 @@ +import CampaignRow from './CampaignRow'; +import { Table, TableBody, TableCaption, TableHead, TableHeader, TableRow } from '@/components/ui/table'; + +import { useCampaigns } from '../../context/CampaignContext'; + +const CampaignTable = () => { + const { campaigns } = useCampaigns(); + + return ( + + A list of your campaigns. + + + Name + Created At + Updated At + Actions + + + + {campaigns.map((campaign) => ( + + ))} + +
+ ); +}; + +export default CampaignTable; diff --git a/apps/frontend/src/components/ContentTable/ContentRow.tsx b/apps/frontend/src/components/ContentTable/ContentRow.tsx new file mode 100644 index 0000000..99f218f --- /dev/null +++ b/apps/frontend/src/components/ContentTable/ContentRow.tsx @@ -0,0 +1,139 @@ +import { TableRow, TableCell } from '@/components/ui/table'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Eye } from 'lucide-react'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; +import { ReviewState, type Campaign, type ContentPiece } from '@/lib/types'; +import { useCallback, useState } from 'react'; +import ReviewDialogForm from './ReviewDialogForms'; +import { Badge } from '../ui/badge'; + +interface ContentRowProps { + campaign?: Campaign; + content: ContentPiece; +} + +const ContentRow = ({ campaign, content }: ContentRowProps) => { + const [showDialog, setShowDialog] = useState(false); + + const onShowReview = () => { + setShowDialog(true); + }; + const onCloseDialog = () => { + setShowDialog(false); + }; + + const contentReviewBadgeVariant = useCallback(() => { + switch (content.reviewState) { + case ReviewState.approved: + return 'green'; + case ReviewState.rejected: + return 'red'; + case ReviewState.draft: + return 'blue'; + case ReviewState.reviewed: + return 'brown'; + case ReviewState.suggested_by_ai: + return 'yellow'; + default: + return 'gray'; + } + }, [content.reviewState]); + + return ( + <> + + {content.id} + {new Date(Date.parse(content.updatedAt)).toLocaleString()} + {content.translations[0]?.languageCode} + + {content.reviewState} + + + + + + + Review + + + + + + + + Review Content Information + + Review a content. You can aprove or reject it in the next steps. + + + {content !== null && ( +
+
+
+

Data

+

+ ID: + {content.id} +

+

+ Created At: + {content.createdAt} +

+

+ Updated At: + {content.updatedAt} +

+

+ Source Language: + {content.sourceLanguage} +

+

+ Review: + {content.reviewState} +

+
+ + {campaign && } +
+
+

Translations

+ {[...content.translations].map((translation) => ( +
+

+ ID: + {translation.id} +

+

+ Model Provider: + {translation.modelProvider || 'N/A'} +

+

+ Language: + {translation.languageCode} +

+

+ Title: + {translation.translatedTitle} +

+

+ Description: + {translation.translatedDescription} +

+

+ Generation Type: + {translation.isAIGenerated ? 'AI Generated' : 'Human Generated'} +

+
+ ))} +
+
+ )} +
+
+ + ); +}; + +export default ContentRow; diff --git a/apps/frontend/src/components/ContentTable/ContentTable.tsx b/apps/frontend/src/components/ContentTable/ContentTable.tsx new file mode 100644 index 0000000..e2be7a3 --- /dev/null +++ b/apps/frontend/src/components/ContentTable/ContentTable.tsx @@ -0,0 +1,30 @@ +import { Table, TableBody, TableCaption, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { useCampaigns } from '../../context/CampaignContext'; +import ContentRow from './ContentRow'; + +const ContentTable = ({ campaignId }: { campaignId: string }) => { + const { campaigns } = useCampaigns(); + const campaign = campaigns.find((c) => c.id === campaignId); + + return ( + + A list of contents for campaign {campaign?.name}. + + + ID + Updated at + Language + Review + Actions + + + + {campaign?.contentPieces.map((content) => ( + + ))} + +
+ ); +}; + +export default ContentTable; diff --git a/apps/frontend/src/components/ContentTable/ReviewDialogForms.tsx b/apps/frontend/src/components/ContentTable/ReviewDialogForms.tsx new file mode 100644 index 0000000..087c54a --- /dev/null +++ b/apps/frontend/src/components/ContentTable/ReviewDialogForms.tsx @@ -0,0 +1,165 @@ +import { + LocaleOptions, + ModelProviderOptions, + useGenerationConfig, + type Locale, + type ModelProvider, +} from '@/context/GenerationConfigContext'; +import { Label } from '../ui/label'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '../ui/select'; + +import { ReviewState, type Campaign, type ContentPiece } from '@/lib/types'; +import { useEffect, useMemo, useState } from 'react'; +import Spinner from '../ui/spinner'; +import { cn } from '@/lib/utils'; +import * as contentPieceAPI from '@/lib/api/contentPiece'; +import { CheckCheck, Trash2 } from 'lucide-react'; + +function ReviewDialogForm({ campaign, content }: { campaign: Campaign; content: ContentPiece }) { + const { locale: defaultLocal, modelProvider: defaultModelProvider } = useGenerationConfig(); + const [locale, setLocale] = useState(defaultLocal); + const [modelProvider, setModelProvider] = useState(defaultModelProvider); + + const [isAIGenerating, setIsAIGenerating] = useState(false); + + const onGenerationRequestSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsAIGenerating(true); + + await contentPieceAPI.generateDraft(campaign.id, locale, modelProvider, content.id); + + setIsAIGenerating(false); + }; + + const [isUpdatingState, setIsUpdatingState] = useState(false); + + const updateState = async (state: 'approved' | 'rejected') => { + setIsUpdatingState(true); + + if (state === 'approved') { + await contentPieceAPI.approve(content.id); + } else if (state === 'rejected') { + await contentPieceAPI.reject(content.id); + } + + setIsUpdatingState(false); + }; + + const canUpdateState = useMemo(() => { + return content.reviewState !== ReviewState.approved && content.reviewState !== ReviewState.rejected; + }, [content.reviewState]); + + useEffect(() => { + if (content.reviewState !== ReviewState.suggested_by_ai) return; + + contentPieceAPI.markAsReviewed(content.id); + }, [content.reviewState]); + + // if content is already approved or rejected, do not show the form + if (!canUpdateState) { + return null; + } + return ( +
+

Do you like the content? Feel free to generate more!

+ +

+ If you choose to generate content in a language that already has a draft, the existing draft will be + overwritten. +

+

+ If you choose to generate content in a language that does not exist yet, a new translation will be created. This + will be a translation of the original content, not a newly generated draft. +

+ +
+
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ ); +} + +export default ReviewDialogForm; diff --git a/apps/frontend/src/components/layout/Header.tsx b/apps/frontend/src/components/layout/Header.tsx new file mode 100644 index 0000000..fa73f3a --- /dev/null +++ b/apps/frontend/src/components/layout/Header.tsx @@ -0,0 +1,68 @@ +import { + useGenerationConfig, + LocaleOptions, + ModelProviderOptions, + type ModelProvider, + type Locale, +} from '@/context/GenerationConfigContext'; +import { Label } from '../ui/label'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '../ui/select'; + +function Header() { + const config = useGenerationConfig(); + + return ( +
+

ACME GLOBAL MEDIA

+ +
+
+ + +
+ +
+ + +
+
+
+ ); +} + +export default Header; diff --git a/apps/frontend/src/components/ui/badge.tsx b/apps/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..12e7423 --- /dev/null +++ b/apps/frontend/src/components/ui/badge.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden', + { + variants: { + variant: { + default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90', + secondary: 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90', + destructive: + 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', + + green: 'border-transparent bg-green-500 text-white [a&]:hover:bg-green-600', + red: 'border-transparent bg-red-500 text-white [a&]:hover:bg-red-600', + blue: 'border-transparent bg-blue-500 text-white [a&]:hover:bg-blue-600', + brown: 'border-transparent bg-yellow-900 text-white [a&]:hover:bg-yellow-950', + yellow: 'border-transparent bg-yellow-500 text-black [a&]:hover:bg-yellow-600', + purple: 'border-transparent bg-purple-500 text-white [a&]:hover:bg-purple-600', + gray: 'border-transparent bg-gray-500 text-white [a&]:hover:bg-gray-600', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<'span'> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : 'span'; + + return ; +} + +export { Badge, badgeVariants }; diff --git a/apps/frontend/src/components/ui/dialog.tsx b/apps/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..18a4d06 --- /dev/null +++ b/apps/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,121 @@ +import * as React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { XIcon } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +function Dialog({ ...props }: React.ComponentProps) { + return ; +} + +function DialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DialogClose({ ...props }: React.ComponentProps) { + return ; +} + +function DialogOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/apps/frontend/src/components/ui/input.tsx b/apps/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..3c1cfca --- /dev/null +++ b/apps/frontend/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Input({ className, type, ...props }: React.ComponentProps<'input'>) { + return ( + + ); +} + +export { Input }; diff --git a/apps/frontend/src/components/ui/label.tsx b/apps/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..1756eaf --- /dev/null +++ b/apps/frontend/src/components/ui/label.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import * as LabelPrimitive from '@radix-ui/react-label'; + +import { cn } from '@/lib/utils'; + +function Label({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { Label }; diff --git a/apps/frontend/src/components/ui/select.tsx b/apps/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..9a8469c --- /dev/null +++ b/apps/frontend/src/components/ui/select.tsx @@ -0,0 +1,158 @@ +import * as React from 'react'; +import * as SelectPrimitive from '@radix-ui/react-select'; +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +function Select({ ...props }: React.ComponentProps) { + return ; +} + +function SelectGroup({ ...props }: React.ComponentProps) { + return ; +} + +function SelectValue({ ...props }: React.ComponentProps) { + return ; +} + +function SelectTrigger({ + className, + size = 'default', + children, + ...props +}: React.ComponentProps & { + size?: 'sm' | 'default'; +}) { + return ( + + {children} + + + + + ); +} + +function SelectContent({ + className, + children, + position = 'popper', + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ); +} + +function SelectLabel({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SelectItem({ className, children, ...props }: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function SelectSeparator({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SelectScrollUpButton({ className, ...props }: React.ComponentProps) { + return ( + + + + ); +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +}; diff --git a/apps/frontend/src/components/ui/sonner.tsx b/apps/frontend/src/components/ui/sonner.tsx new file mode 100644 index 0000000..225d010 --- /dev/null +++ b/apps/frontend/src/components/ui/sonner.tsx @@ -0,0 +1,23 @@ +import { useTheme } from 'next-themes'; +import { Toaster as Sonner, type ToasterProps } from 'sonner'; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = 'system' } = useTheme(); + + return ( + + ); +}; + +export { Toaster }; diff --git a/apps/frontend/src/components/ui/spinner.tsx b/apps/frontend/src/components/ui/spinner.tsx new file mode 100644 index 0000000..a52d211 --- /dev/null +++ b/apps/frontend/src/components/ui/spinner.tsx @@ -0,0 +1,11 @@ +import { LoaderCircle } from 'lucide-react'; + +function Spinner(props: { color: string; size: number }) { + return ( +
+ +
+ ); +} + +export default Spinner; diff --git a/apps/frontend/src/components/ui/table.tsx b/apps/frontend/src/components/ui/table.tsx new file mode 100644 index 0000000..84dec5c --- /dev/null +++ b/apps/frontend/src/components/ui/table.tsx @@ -0,0 +1,73 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Table({ className, ...props }: React.ComponentProps<'table'>) { + return ( +
+ + + ); +} + +function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) { + return ; +} + +function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) { + return ; +} + +function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) { + return ( + tr]:last:border-b-0', className)} + {...props} + /> + ); +} + +function TableRow({ className, ...props }: React.ComponentProps<'tr'>) { + return ( + + ); +} + +function TableHead({ className, ...props }: React.ComponentProps<'th'>) { + return ( +
[role=checkbox]]:translate-y-[2px]', + className, + )} + {...props} + /> + ); +} + +function TableCell({ className, ...props }: React.ComponentProps<'td'>) { + return ( + [role=checkbox]]:translate-y-[2px]', + className, + )} + {...props} + /> + ); +} + +function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) { + return ( +
+ ); +} + +export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }; diff --git a/apps/frontend/src/components/ui/tooltip.tsx b/apps/frontend/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..6724a2a --- /dev/null +++ b/apps/frontend/src/components/ui/tooltip.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import * as TooltipPrimitive from '@radix-ui/react-tooltip'; +import { Provider as TooltipPrimitiveProvider } from '@radix-ui/react-tooltip'; + +import { cn } from '@/lib/utils'; + +function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps) { + return ; +} + +function Tooltip({ ...props }: React.ComponentProps) { + return ( + + + + ); +} + +function TooltipTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ); +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/apps/frontend/src/context/CampaignContext.tsx b/apps/frontend/src/context/CampaignContext.tsx new file mode 100644 index 0000000..1b9f878 --- /dev/null +++ b/apps/frontend/src/context/CampaignContext.tsx @@ -0,0 +1,289 @@ +import type { Campaign, ContentPiece, ContentPieceTranslation } from '@/lib/types'; +import React, { createContext, use, useState, useMemo, type ReactNode, useEffect } from 'react'; +import { gql } from '@apollo/client'; +import { client } from '@/lib/apolloClient'; + +// Define the context value type +type CampaignContextType = { + campaigns: Campaign[]; + setCampaigns: React.Dispatch>; +}; + +// Create the context +const CampaignContext = createContext(undefined); + +type UpdateInfo = { + [K in keyof T]: T[K]; +} & { _type: 'create' | 'update' | 'remove' }; + +// Define the subscription query +const CAMPAIGN_GET_ALL_QUERY = gql` + query GetCampaigns { + campaigns { + id + name + description + createdAt + updatedAt + contentPieces { + id + reviewState + aiGeneratedDraft + sourceLanguage + createdAt + updatedAt + translations { + id + modelProvider + languageCode + translatedTitle + translatedDescription + isAIGenerated + isHumanEdited + createdAt + updatedAt + } + } + } + } +`; + +const CAMPAIGN_UPDATED_SUBSCRIPTION = gql` + subscription campaignUpdated { + campaignUpdated { + id + name + description + createdAt + updatedAt + _type + contentPieces { + id + reviewState + aiGeneratedDraft + sourceLanguage + createdAt + updatedAt + translations { + id + modelProvider + languageCode + translatedTitle + translatedDescription + isAIGenerated + isHumanEdited + createdAt + updatedAt + } + } + } + } +`; + +const CONTENT_PIECE_UPDATED_SUBSCRIPTION = gql` + subscription contentPieceUpdated { + contentPieceUpdated { + id + campaignId + reviewState + aiGeneratedDraft + sourceLanguage + createdAt + updatedAt + _type + translations { + id + modelProvider + languageCode + translatedTitle + translatedDescription + isAIGenerated + isHumanEdited + createdAt + updatedAt + } + } + } +`; + +const CONTENT_PIECE_TRANSLATION_UPDATED_SUBSCRIPTION = gql` + subscription contentPieceTranslationUpdated { + contentPieceTranslationUpdated { + id + modelProvider + languageCode + translatedTitle + translatedDescription + isAIGenerated + isHumanEdited + createdAt + updatedAt + + _type + campaignId + contentPieceId + } + } +`; + +// Create the provider component +export const CampaignProvider = ({ children }: { children: ReactNode }) => { + const [campaigns, setCampaigns] = useState([]); + + // query for initial campaigns + useEffect(() => { + client + .query<{ campaigns: Campaign[] }>({ + query: CAMPAIGN_GET_ALL_QUERY, + fetchPolicy: 'network-only', // Always fetch from network to get the latest data + }) + .then((result) => { + if (result.data && result.data.campaigns) { + setCampaigns(result.data.campaigns); + } + }) + .catch((error) => { + console.error('Error fetching campaigns:', error); + }); + }, []); + + // Subscribe to campaign updates + useEffect(() => { + const campaignObservable = client.subscribe<{ campaignUpdated: UpdateInfo }>({ + query: CAMPAIGN_UPDATED_SUBSCRIPTION, + }); + const campaignSubscription = campaignObservable.subscribe({ + next: (result) => { + if (!result.data) return; + + const updatedCampaign = result.data.campaignUpdated; + setCampaigns((prevCampaigns) => { + const index = prevCampaigns.findIndex((c) => c.id === updatedCampaign.id); + if (index === -1) { + // Add new campaign + return [...prevCampaigns, updatedCampaign]; + } + const updatedCampaigns = [...prevCampaigns]; + if (updatedCampaign._type === 'remove') { + updatedCampaigns.splice(index, 1); + return updatedCampaigns; + } + // Update existing campaign + updatedCampaigns[index] = updatedCampaign; + return updatedCampaigns; + }); + }, + }); + + // Subscribe to content piece updates + const contentPieceObservable = client.subscribe<{ + contentPieceUpdated: UpdateInfo; + }>({ + query: CONTENT_PIECE_UPDATED_SUBSCRIPTION, + }); + const contentPieceSubscription = contentPieceObservable.subscribe({ + next: (result) => { + if (!result.data) return; + + const updatedContentPiece = result.data.contentPieceUpdated; + setCampaigns((prevCampaigns) => { + const campaignIndex = prevCampaigns.findIndex((c) => c.id === updatedContentPiece.campaignId); + if (campaignIndex === -1) return prevCampaigns; // Campaign not found + + const updatedCampaigns = [...prevCampaigns]; + const contentPieces = [...updatedCampaigns[campaignIndex].contentPieces]; + const contentIndex = contentPieces.findIndex((cp) => cp.id === updatedContentPiece.id); + + if (contentIndex === -1) { + // Add new content piece + contentPieces.push(updatedContentPiece); + } else { + if (updatedContentPiece._type === 'remove') { + contentPieces.splice(contentIndex, 1); + } else { + // Update existing content piece + contentPieces[contentIndex] = updatedContentPiece; + } + } + updatedCampaigns[campaignIndex] = { + ...updatedCampaigns[campaignIndex], + contentPieces, + }; + return updatedCampaigns; + }); + }, + }); + + // Subscribe to content piece translation updates + const contentPieceTranslationObservable = client.subscribe<{ + contentPieceTranslationUpdated: UpdateInfo< + ContentPieceTranslation & { campaignId: string; contentPieceId: string } + >; + }>({ + query: CONTENT_PIECE_TRANSLATION_UPDATED_SUBSCRIPTION, + }); + const contentPieceTranslationSubscription = contentPieceTranslationObservable.subscribe({ + next: (result) => { + if (!result.data) return; + + const updatedTranslation = result.data.contentPieceTranslationUpdated; + setCampaigns((prevCampaigns) => { + const campaignIndex = prevCampaigns.findIndex((c) => c.id === updatedTranslation.campaignId); + if (campaignIndex === -1) return prevCampaigns; // Campaign not found + + const updatedCampaigns = [...prevCampaigns]; + const contentPieces = updatedCampaigns[campaignIndex].contentPieces; + const contentIndex = contentPieces.findIndex((cp) => cp.id === updatedTranslation.contentPieceId); + if (contentIndex === -1) return prevCampaigns; // ContentPiece piece not found + + const translations = [...contentPieces[contentIndex].translations]; + const translationIndex = translations.findIndex((t) => t.id === updatedTranslation.id); + + if (translationIndex === -1) { + // Add new translation + translations.push(updatedTranslation); + } else { + if (updatedTranslation._type === 'remove') { + translations.splice(translationIndex, 1); + } else { + // Update existing translation + translations[translationIndex] = updatedTranslation; + } + } + + updatedCampaigns[campaignIndex] = { + ...updatedCampaigns[campaignIndex], + contentPieces: [ + ...contentPieces.slice(0, contentIndex), + { + ...contentPieces[contentIndex], + translations, + }, + ...contentPieces.slice(contentIndex + 1), + ].sort((a, b) => a.createdAt.localeCompare(b.createdAt)), + }; + return updatedCampaigns; + }); + }, + }); + + // Cleanup subscriptions on unmount + return () => { + campaignSubscription.unsubscribe(); + contentPieceSubscription.unsubscribe(); + contentPieceTranslationSubscription.unsubscribe(); + }; + }, []); + + const value = useMemo(() => ({ campaigns, setCampaigns }), [campaigns]); + return {children}; +}; + +// Custom hook to use the CampaignContext +export const useCampaigns = () => { + const context = use(CampaignContext); + if (!context) { + throw new Error('useCampaigns must be used within a CampaignProvider'); + } + return context; +}; diff --git a/apps/frontend/src/context/GenerationConfigContext.tsx b/apps/frontend/src/context/GenerationConfigContext.tsx new file mode 100644 index 0000000..2e6a957 --- /dev/null +++ b/apps/frontend/src/context/GenerationConfigContext.tsx @@ -0,0 +1,39 @@ +import { createContext, use, useMemo, useState, type ReactNode } from 'react'; + +export const ModelProviderOptions = { + openai: 'OpenAI', + anthropic: 'Anthropic', +}; +export type ModelProvider = keyof typeof ModelProviderOptions; + +export const LocaleOptions = { + 'en-EN': 'English', + 'es-ES': 'Spanish', + 'fr-FR': 'French', +}; +export type Locale = keyof typeof LocaleOptions; + +export interface GenerationConfigContextProps { + locale: Locale; + setLocale: (locale: Locale) => void; + modelProvider: ModelProvider; + setModelProvider: (provider: ModelProvider) => void; +} + +const GenerationConfigContext = createContext(undefined); + +export const GenerationConfigProvider = ({ children }: { children: ReactNode }) => { + const [locale, setLocale] = useState('en-EN'); + const [modelProvider, setModelProvider] = useState('openai'); + + const value = useMemo(() => ({ locale, setLocale, modelProvider, setModelProvider }), [locale, modelProvider]); + return {children}; +}; + +export const useGenerationConfig = () => { + const context = use(GenerationConfigContext); + if (!context) { + throw new Error('useGenerationConfig must be used within a GenerationConfigProvider'); + } + return context; +}; diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css new file mode 100644 index 0000000..d5821b5 --- /dev/null +++ b/apps/frontend/src/index.css @@ -0,0 +1,157 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +@custom-variant dark (&:is(.dark *)); + +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + --radius: 0.625rem; + --background: oklch(14.5% 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(27.503% 0.0185 294.29); + --popover-foreground: oklch(91.279% 0.0001 271.152); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.4 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +a { /* Link component uses a tag */ + @apply font-medium text-white hover:text-purple-500; +} + +button { /* just update the cursor for all buttons */ + @apply cursor-pointer; +} + +/* Always use dark mode scrollbars with Tailwind CSS utility classes */ +::-webkit-scrollbar { + @apply w-3 bg-zinc-900 rounded-3xl; +} + +::-webkit-scrollbar-thumb { + @apply bg-zinc-800 rounded-md border-2 border-solid border-zinc-900; +} + +::-webkit-scrollbar-thumb:hover { + @apply bg-zinc-700; +} + +::-webkit-scrollbar-corner { + @apply bg-zinc-900; +} diff --git a/apps/frontend/src/lib/api/campaign.ts b/apps/frontend/src/lib/api/campaign.ts new file mode 100644 index 0000000..13d7642 --- /dev/null +++ b/apps/frontend/src/lib/api/campaign.ts @@ -0,0 +1,49 @@ +import type { Campaign } from '../types'; +import { api } from './util'; +import { toast } from 'sonner'; + +export async function create(data: Partial) { + try { + const response = await api.post>('/campaigns', data); + + toast.success('Campaign has been created', { + description: new Date().toLocaleString(), + }); + return response; + } catch (error) { + toast.error('Campaign has not been created', { + description: new Date().toLocaleString(), + }); + } +} + +export async function update(campaignId: string, data: Partial) { + try { + const response = await api.put>(`/campaigns/${campaignId}`, data); + + toast.success('Campaign has been updated', { + description: new Date().toLocaleString(), + }); + return response; + } catch (error) { + toast.error('Campaign has not been updated', { + description: new Date().toLocaleString(), + }); + return null; + } +} + +export async function remove(campaignId: string) { + try { + const response = await api.delete(`/campaigns/${campaignId}`); + toast.success('Campaign has been deleted', { + description: new Date().toLocaleString(), + }); + return response; + } catch (error) { + toast.error('Campaign has not been deleted', { + description: new Date().toLocaleString(), + }); + return null; + } +} diff --git a/apps/frontend/src/lib/api/contentPiece.ts b/apps/frontend/src/lib/api/contentPiece.ts new file mode 100644 index 0000000..994434a --- /dev/null +++ b/apps/frontend/src/lib/api/contentPiece.ts @@ -0,0 +1,127 @@ +import type { ModelProvider } from '@/context/GenerationConfigContext'; +import type { ContentPiece } from '../types'; +import { api } from './util'; +import { toast } from 'sonner'; + +export async function create(data: ContentPiece) { + try { + const response = await api.post>('/content-pieces', data); + toast.success('Content piece has been created', { + description: new Date().toLocaleString(), + }); + return response; + } catch (error) { + toast.error('Content piece has NOT been created', { + description: new Date().toLocaleString(), + }); + return null; + } +} + +export async function update(contentPieceId: string, data: Partial) { + try { + const response = await api.put>(`/content-pieces/${contentPieceId}`, data); + toast.success('Content piece has been updated', { + description: new Date().toLocaleString(), + }); + return response; + } catch (error) { + toast.error('Content piece has NOT been updated', { + description: new Date().toLocaleString(), + }); + return null; + } +} + +export async function remove(contentPieceId: string) { + try { + const response = await api.delete(`/content-pieces/${contentPieceId}`); + toast.success('Content piece has been deleted', { + description: new Date().toLocaleString(), + }); + return response; + } catch (error) { + toast.error('Content piece has NOT been deleted', { + description: new Date().toLocaleString(), + }); + return null; + } +} + +export async function generateDraft( + campaignId: string, + locale: string, + modelProvider: ModelProvider, + contentId?: string, +) { + try { + const response = await api.post< + ContentPiece, + { + campaignId: string; + locale: string; + modelProvider: ModelProvider; + id?: string; + } + >(`/content-pieces/generate`, { + campaignId, + locale, + modelProvider, + id: contentId, + }); + + toast.success('Content piece draft generated', { + description: new Date().toLocaleString(), + }); + return response; + } catch (error) { + toast.error('Content piece draft generation failed', { + description: new Date().toLocaleString(), + }); + return null; + } +} + +// approve or reject a content piece +export async function approve(contentPieceId: string) { + try { + const response = await api.post(`/content-pieces/${contentPieceId}/approve`, undefined); + toast.success('Content piece has been approved', { + description: new Date().toLocaleString(), + }); + return response; + } catch (error) { + toast.error('Content piece has NOT been approved', { + description: new Date().toLocaleString(), + }); + return null; + } +} + +export async function reject(contentPieceId: string) { + try { + const response = await api.post(`/content-pieces/${contentPieceId}/reject`, undefined); + toast.success('Content piece has been rejected', { + description: new Date().toLocaleString(), + }); + return response; + } catch (error) { + toast.error('Content piece has NOT been rejected', { + description: new Date().toLocaleString(), + }); + return null; + } +} + +// update to reviewed +export async function markAsReviewed(contentPieceId: string) { + try { + const response = await api.post(`/content-pieces/${contentPieceId}/reviewed`, undefined); + return response; + } catch (error) { + toast.error("Content piece has NOT been marked as 'Reviewed'", { + description: new Date().toLocaleString(), + }); + return null; + } +} diff --git a/apps/frontend/src/lib/api/contentPieceTranslation.ts b/apps/frontend/src/lib/api/contentPieceTranslation.ts new file mode 100644 index 0000000..516325d --- /dev/null +++ b/apps/frontend/src/lib/api/contentPieceTranslation.ts @@ -0,0 +1,56 @@ +import type { ContentPieceTranslation } from '../types'; +import { api } from './util'; +import { toast } from 'sonner'; + +export async function create(contentPieceTranslation: ContentPieceTranslation) { + try { + const response = await api.post>( + '/content-piece-translations', + contentPieceTranslation, + ); + toast.success('Content piece translation has been created', { + description: new Date().toLocaleString(), + }); + return response; + } catch (error) { + toast.error('Content piece translation has not been created', { + description: new Date().toLocaleString(), + }); + return null; + } +} + +export async function update(contentPieceTranslationId: string, data: Partial) { + try { + const response = await api.put>( + `/content-piece-translations/${contentPieceTranslationId}`, + data, + ); + toast.success('Content piece translation has been updated', { + description: new Date().toLocaleString(), + }); + return response; + } catch (error) { + toast.error('Content piece translation has not been updated', { + description: new Date().toLocaleString(), + }); + return null; + } +} + +export async function remove(contentPieceTranslationId: string) { + try { + const response = await api.delete( + `/content-piece-translations/${contentPieceTranslationId}`, + ); + toast.success('Content piece translation has been deleted', { + description: new Date().toLocaleString(), + }); + return response; + } catch (error) { + toast.error('Content piece translation has not been deleted', { + description: new Date().toLocaleString(), + }); + return null; + } +} diff --git a/apps/frontend/src/lib/api/util.ts b/apps/frontend/src/lib/api/util.ts new file mode 100644 index 0000000..f636855 --- /dev/null +++ b/apps/frontend/src/lib/api/util.ts @@ -0,0 +1,37 @@ +import { API_BASE_URL } from '@/lib/config'; +import { HttpError } from '@/lib/errors'; + +async function fetchFromAPI(endpoint: string, options: RequestInit = {}): Promise { + const url = `${API_BASE_URL}${endpoint}`; + + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }, + }); + + if (!response.ok) { + const errorBody = await response.json(); + throw new HttpError(response.status, errorBody.message || 'API Error'); + } + + return response.json(); +} + +// Define API methods +export const api = { + get: (endpoint: string) => fetchFromAPI(endpoint, { method: 'GET' }), + post: (endpoint: string, body: U) => + fetchFromAPI(endpoint, { + method: 'POST', + body: JSON.stringify(body), + }), + put: (endpoint: string, body: U) => + fetchFromAPI(endpoint, { + method: 'PUT', + body: JSON.stringify(body), + }), + delete: (endpoint: string) => fetchFromAPI(endpoint, { method: 'DELETE' }), +}; diff --git a/apps/frontend/src/lib/apolloClient.ts b/apps/frontend/src/lib/apolloClient.ts new file mode 100644 index 0000000..b795d04 --- /dev/null +++ b/apps/frontend/src/lib/apolloClient.ts @@ -0,0 +1,32 @@ +import { ApolloClient, InMemoryCache, HttpLink, ApolloLink } from '@apollo/client'; +import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; +import { createClient } from 'graphql-ws'; +import { getMainDefinition } from '@apollo/client/utilities'; + +// HTTP link for queries and mutations +const httpLink = new HttpLink({ + uri: 'http://localhost:3000/graphql', // Replace with your GraphQL endpoint +}); + +// WebSocket link for subscriptions +const wsLink = new GraphQLWsLink( + createClient({ + url: 'ws://localhost:3000/graphql', // Replace with your GraphQL WebSocket endpoint + }), +); + +// Split links based on operation type +const splitLink = ApolloLink.split( + ({ query }) => { + const definition = getMainDefinition(query); + return definition.kind === 'OperationDefinition' && definition.operation === 'subscription'; + }, + wsLink, + httpLink, +); + +// Apollo Client instance +export const client = new ApolloClient({ + link: splitLink, + cache: new InMemoryCache(), +}); diff --git a/apps/frontend/src/lib/config.ts b/apps/frontend/src/lib/config.ts new file mode 100644 index 0000000..96459fe --- /dev/null +++ b/apps/frontend/src/lib/config.ts @@ -0,0 +1,2 @@ +export const API_BASE_URL: string = import.meta.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000'; +export const GRAPHQL_BASE_URL: string = import.meta.env.NEXT_PUBLIC_GRAPHQL_BASE_URL || 'ws://localhost:3000/graphql'; diff --git a/apps/frontend/src/lib/errors.ts b/apps/frontend/src/lib/errors.ts new file mode 100644 index 0000000..8e6da3e --- /dev/null +++ b/apps/frontend/src/lib/errors.ts @@ -0,0 +1,9 @@ +export class HttpError extends Error { + public status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + this.name = 'HttpError'; + } +} diff --git a/apps/frontend/src/lib/types.ts b/apps/frontend/src/lib/types.ts new file mode 100644 index 0000000..2637f81 --- /dev/null +++ b/apps/frontend/src/lib/types.ts @@ -0,0 +1,43 @@ +export type Campaign = { + id: string; + name: string; + description: string; + createdAt: string; + updatedAt: string; + + contentPieces: ContentPiece[]; +}; + +export const ReviewState = { + draft: 'Draft', + suggested_by_ai: 'SuggestedByAI', + reviewed: 'Reviewed', + approved: 'Approved', + rejected: 'Rejected', +} as const; +export type ReviewStateType = (typeof ReviewState)[keyof typeof ReviewState]; + +export type ContentPiece = { + id: string; + reviewState: ReviewStateType; + aiGeneratedDraft?: object; + sourceLanguage: string; + + createdAt: string; + updatedAt: string; + + translations: ContentPieceTranslation[]; +}; + +export type ContentPieceTranslation = { + id: string; + languageCode: string; + translatedTitle: string; + translatedDescription: string; + isAIGenerated: boolean; + isHumanEdited: boolean; + modelProvider?: string; + + createdAt: string; + updatedAt: string; +}; diff --git a/apps/frontend/src/lib/utils.ts b/apps/frontend/src/lib/utils.ts new file mode 100644 index 0000000..2819a83 --- /dev/null +++ b/apps/frontend/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/apps/frontend/src/lib/validators.ts b/apps/frontend/src/lib/validators.ts new file mode 100644 index 0000000..39eb51d --- /dev/null +++ b/apps/frontend/src/lib/validators.ts @@ -0,0 +1,5 @@ +export const isValidUUID = (id: string | undefined): boolean => { + if (!id) return false; + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + return uuidRegex.test(id); +}; diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx new file mode 100644 index 0000000..cc14d83 --- /dev/null +++ b/apps/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import './index.css'; +import App from './App'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/apps/frontend/src/pages/CampaignCreate.tsx b/apps/frontend/src/pages/CampaignCreate.tsx new file mode 100644 index 0000000..f12dd0c --- /dev/null +++ b/apps/frontend/src/pages/CampaignCreate.tsx @@ -0,0 +1,85 @@ +import Spinner from '@/components/ui/spinner'; +import { ArrowBigLeftDash } from 'lucide-react'; +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import * as campaignAPI from '@/lib/api/campaign'; +import Header from '@/components/layout/Header'; + +function CampaignCreate() { + const [newCampaignName, setNewCampaignName] = useState(''); + const [newCampaignDescription, setNewCampaignDescription] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const navigator = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + + await campaignAPI.create({ + name: newCampaignName, + description: newCampaignDescription, + }); + navigator('/campaigns'); + + setIsLoading(false); + }; + + return ( + <> +
+
+

Create a Campaign

+ +
+ + Back to Campaigns + +
+
+ +
+
+ + setNewCampaignName(e.target.value)} + required + /> +
+ +
+ +