Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8026a25278 | |||
| e43680f798 | |||
| fe534a7875 | |||
| 6241c25af0 | |||
| a11194831d | |||
| 325fe9735b | |||
| a79a3af3d5 | |||
| c0eea843bf | |||
| a59bdc7711 | |||
| b743608742 | |||
| d0428aff0c | |||
| dea569df99 | |||
| 91b256a45a | |||
| 2b6eef6509 | |||
| fbb4d29e5d | |||
| 01a3c1a776 | |||
| e218ad57e8 | |||
| b9a4a45781 | |||
| 64588935ca | |||
| bba5a018ab | |||
| aa43f50a8c | |||
| 46bbeed525 | |||
| 7258f059ce | |||
| a66db56156 | |||
| ebd1fc28d5 | |||
| cba9908180 | |||
| 287dcb4070 | |||
| 50ac4531d3 | |||
| 79bd134977 | |||
| 88dbd2719d | |||
| e71dc2b90f | |||
| caf56d9fcd | |||
| 86c1c2d366 | |||
| 4f2837ad4e | |||
| c6427304ac | |||
| 515c4cacf3 | |||
| 59891b03dc | |||
| 8f68dab53f | |||
| fa7dcc2b08 | |||
| 3e61fe5694 | |||
| a5008a3646 | |||
| cbdb801655 | |||
| a5c57b33f7 | |||
| b39972102a | |||
| 8fbde18d02 | |||
| 94b0239a0a | |||
| ac610e6f6a | |||
| 8eaaf4afad | |||
| 33008b64ce | |||
| 005d368dc7 | |||
| a8c0ab8884 | |||
| e443ece848 | |||
| 76963f6eea | |||
| bfc7e76f17 | |||
| 06a0c9fe05 | |||
| c1b49a4cb6 | |||
| 4f30542014 | |||
| d7281024bf | |||
| 4a22e8a18a | |||
| 994becc687 | |||
| 69e758b841 | |||
| 17c6a1593c | |||
| d573846e5c | |||
| c3b01de12e | |||
| 80a2ee89a6 | |||
| ff0916b37c | |||
| 6eb68b7bd3 | |||
| fcbd082f6d | |||
| 183636bceb | |||
| 5262e73a12 | |||
| 590a96e502 | |||
| 0ef26ba2f1 | |||
| 2efdf20f88 | |||
| ed723bc429 | |||
| b8dfde512c | |||
| 5fc424408e | |||
| 749ff5659c | |||
| a2caaab26a | |||
| bc3e834a39 | |||
| 7a96b52cfc | |||
| 5233eec96a | |||
| 831da0af4d | |||
| a9528582fd | |||
| ee676b3916 | |||
| 9013bc51f3 | |||
| 57ae0bb3b5 | |||
| 3b827a90ac | |||
| 3af4b92e1b | |||
| cfc43a7119 | |||
| dc93a8b652 | |||
| 69833d2067 | |||
| d041575600 | |||
| 57864e00da | |||
| 0bbf2ab56f | |||
| 26a17b5a4c | |||
| 7725674fd5 | |||
| f1f16a80a5 | |||
| 83c60ce40d | |||
| f20842b3ab | |||
| 8510cccb6e | |||
| c2c77ac92a | |||
| 71b1b549c3 | |||
| aa79eba06d | |||
| 62c68e4bc6 | |||
| 2be6216772 | |||
| ae28f56337 | |||
| 10fe702749 | |||
| 4c4d741b1f | |||
| 6abae13dbd | |||
| 46eb41aaa5 | |||
| add12b2fbf | |||
| 6d65d5975c | |||
| b8779e5a35 | |||
| 000ebd388a | |||
| 3374eb1ec0 | |||
| d7b82c0ec9 | |||
| 3718882f5b | |||
| cb2355adcd | |||
| c6e602d354 | |||
| 42002f8e6f | |||
| 7378d37b36 | |||
| 9ca05f5ea1 | |||
| 89b8687431 | |||
| 3bc534489d | |||
| 6467e21019 | |||
| 28609a6492 | |||
| 7d3bc2a014 | |||
| acfbc80d1a | |||
| 0c2433cac6 | |||
| 7c7bb45446 | |||
| 2ff76ffda5 | |||
| 4ccb65ba88 | |||
| 5a457fa99f | |||
| 0351726eeb |
276
ADMINISTRATOR_GUIDE.md
Normal file
276
ADMINISTRATOR_GUIDE.md
Normal file
@ -0,0 +1,276 @@
|
||||
# Administrator Guide - Placebo.mk
|
||||
|
||||
## Overview
|
||||
Placebo.mk is a Macedonian news site with a sarcastic tone. This guide covers system administration, user management, and technical operations.
|
||||
|
||||
## System Architecture
|
||||
- **Frontend**: TanStack (React 19, Query, Router) + Vite + Tailwind CSS
|
||||
- **Backend**: NestJS + TypeORM + SQLite
|
||||
- **CMS**: Strapi for content management
|
||||
- **Database**: SQLite with TypeORM entities
|
||||
|
||||
## Environment Setup
|
||||
|
||||
### Backend Configuration
|
||||
```bash
|
||||
cd backend
|
||||
cp .env.example .env
|
||||
# Configure environment variables:
|
||||
# DATABASE_PATH=./data/app.db
|
||||
# PORT=3000
|
||||
# NODE_ENV=development
|
||||
npm install
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
### Frontend Configuration
|
||||
```bash
|
||||
cd frontend
|
||||
cp .env.example .env
|
||||
# Configure environment variables:
|
||||
# VITE_API_URL=http://localhost:3000
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### CMS (Strapi) Configuration
|
||||
```bash
|
||||
cd cms/cms
|
||||
cp .env.example .env
|
||||
# Configure Strapi environment variables:
|
||||
# DATABASE_CLIENT=sqlite
|
||||
# DATABASE_FILENAME=./data/app.db
|
||||
# API_TOKEN_SALT=
|
||||
# ADMIN_JWT_SECRET=
|
||||
npm run develop
|
||||
```
|
||||
|
||||
## Database Management
|
||||
|
||||
### Entities Overview
|
||||
- **Articles**: News articles with status (draft/published/archived)
|
||||
- **Authors**: Writer profiles with permissions
|
||||
- **Categories**: Hierarchical content organization
|
||||
- **Live Blogs**: Real-time event coverage
|
||||
- **Live Blog Updates**: Individual updates within live blogs
|
||||
|
||||
### Database Operations
|
||||
```bash
|
||||
# Run migrations (if implemented)
|
||||
npm run migration:run
|
||||
|
||||
# Check database schema
|
||||
npm run schema:sync
|
||||
|
||||
# Backup database
|
||||
cp backend/data/app.db backend/data/backup-$(date +%Y%m%d).db
|
||||
```
|
||||
|
||||
## User Management
|
||||
|
||||
### Author Management
|
||||
Authors are stored in the `authors` table with:
|
||||
- Basic profile (name, bio, avatar)
|
||||
- Unique slug for URLs
|
||||
- `isActive` flag for permissions
|
||||
- Relations to articles and live blogs
|
||||
|
||||
### CMS User Roles
|
||||
1. **Super Admin**: Full system access
|
||||
2. **Admin**: Content and user management
|
||||
3. **Editor**: Content creation and editing
|
||||
4. **Author**: Limited to assigned content
|
||||
|
||||
### Creating New Authors
|
||||
```sql
|
||||
INSERT INTO authors (id, name, slug, bio, avatar, isActive, createdAt, updatedAt)
|
||||
VALUES (
|
||||
uuid(),
|
||||
'Author Name',
|
||||
'author-slug',
|
||||
'Author bio',
|
||||
'avatar-url.jpg',
|
||||
true,
|
||||
datetime('now'),
|
||||
datetime('now')
|
||||
);
|
||||
```
|
||||
|
||||
## Content Management
|
||||
|
||||
### Article Workflow
|
||||
1. **Draft**: Initial creation phase
|
||||
2. **Published**: Publicly visible
|
||||
3. **Archived**: No longer public but retained
|
||||
|
||||
### Live Blog Management
|
||||
Live blogs support real-time updates:
|
||||
- **Draft**: Preparation phase
|
||||
- **Live**: Active event coverage
|
||||
- **Ended**: Coverage complete
|
||||
- **Archived**: Historical reference
|
||||
|
||||
## API Management
|
||||
|
||||
### Available Endpoints
|
||||
```
|
||||
GET /api/v1/articles # List articles
|
||||
GET /api/v1/articles/:id # Get single article
|
||||
GET /api/v1/articles/slug/:slug # Get article by slug
|
||||
POST /api/v1/articles # Create article
|
||||
PUT /api/v1/articles/:id # Update article
|
||||
DELETE /api/v1/articles/:id # Delete article
|
||||
|
||||
GET /api/v1/live-blogs # List live blogs
|
||||
GET /api/v1/live-blogs/:id # Get live blog
|
||||
POST /api/v1/live-blogs # Create live blog
|
||||
PUT /api/v1/live-blogs/:id # Update live blog
|
||||
|
||||
GET /api/v1/authors # List authors
|
||||
GET /api/v1/categories # List categories
|
||||
```
|
||||
|
||||
### API Authentication
|
||||
Configure JWT tokens for secure API access:
|
||||
```typescript
|
||||
// In .env
|
||||
JWT_SECRET=your-secret-key
|
||||
JWT_EXPIRES_IN=24h
|
||||
```
|
||||
|
||||
## CMS Administration
|
||||
|
||||
### Strapi Admin Panel
|
||||
Access at: `http://localhost:1337/admin`
|
||||
|
||||
### Content Types Configuration
|
||||
Articles support:
|
||||
- Rich text content
|
||||
- Multiple media files (images, files, videos, audio)
|
||||
- Featured images
|
||||
- Author attribution
|
||||
|
||||
### Media Management
|
||||
- Upload limits configured in Strapi settings
|
||||
- Image optimization handled automatically
|
||||
- File organization in `/uploads` directory
|
||||
|
||||
## Performance Monitoring
|
||||
|
||||
### Backend Monitoring
|
||||
```bash
|
||||
# Check application logs
|
||||
npm run start:prod
|
||||
|
||||
# Monitor performance
|
||||
npm run start:prod -- --inspect
|
||||
```
|
||||
|
||||
### Frontend Performance
|
||||
```bash
|
||||
# Build for production
|
||||
cd frontend
|
||||
npm run build
|
||||
|
||||
# Analyze bundle size
|
||||
npm run build:analyze
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Authentication
|
||||
- Implement proper JWT handling
|
||||
- Use secure password hashing
|
||||
- Set appropriate CORS policies
|
||||
|
||||
### Data Protection
|
||||
- Regular database backups
|
||||
- Environment variable protection
|
||||
- Input validation and sanitization
|
||||
|
||||
### CMS Security
|
||||
- Regular Strapi updates
|
||||
- Role-based access control
|
||||
- Media file scanning
|
||||
|
||||
## Backup and Recovery
|
||||
|
||||
### Automated Backups
|
||||
```bash
|
||||
# Create backup script
|
||||
#!/bin/bash
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
mkdir -p backups
|
||||
cp backend/data/app.db backups/app_$DATE.db
|
||||
tar -czf backups/uploads_$DATE.tar.gz cms/cms/public/uploads/
|
||||
```
|
||||
|
||||
### Recovery Process
|
||||
1. Stop all services
|
||||
2. Restore database from backup
|
||||
3. Restore media files
|
||||
4. Restart services
|
||||
5. Verify functionality
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Database Connection Errors
|
||||
```bash
|
||||
# Check database file exists
|
||||
ls -la backend/data/app.db
|
||||
|
||||
# Verify permissions
|
||||
chmod 664 backend/data/app.db
|
||||
```
|
||||
|
||||
#### Strapi Admin Access
|
||||
```bash
|
||||
# Reset admin password
|
||||
npm run strapi admin:reset-password
|
||||
```
|
||||
|
||||
#### Frontend Build Issues
|
||||
```bash
|
||||
# Clear cache
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
|
||||
# Check environment variables
|
||||
cat .env
|
||||
```
|
||||
|
||||
## Maintenance Tasks
|
||||
|
||||
### Daily
|
||||
- Monitor system logs
|
||||
- Check backup completion
|
||||
- Review performance metrics
|
||||
|
||||
### Weekly
|
||||
- Update dependencies
|
||||
- Review content moderation queue
|
||||
- Security scan
|
||||
|
||||
### Monthly
|
||||
- Database maintenance
|
||||
- Archive old content
|
||||
- Performance optimization review
|
||||
|
||||
## Scaling Considerations
|
||||
|
||||
### Database Scaling
|
||||
- Consider PostgreSQL for high traffic
|
||||
- Implement read replicas
|
||||
- Optimize queries and indexes
|
||||
|
||||
### CDN Integration
|
||||
- Configure CDN for static assets
|
||||
- Optimize image delivery
|
||||
- Implement caching strategies
|
||||
|
||||
### Monitoring Setup
|
||||
- Application performance monitoring
|
||||
- Error tracking and alerting
|
||||
- User analytics and reporting
|
||||
244
AGENTS.md
244
AGENTS.md
@ -1,151 +1,169 @@
|
||||
# Agent Guidelines for placebo.mk
|
||||
|
||||
## Project Overview
|
||||
News site in Macedonia with sarcastic tone. Minimalistic design using TanStack stack.
|
||||
Macedonian satirical news site using TanStack stack + NestJS backend.
|
||||
|
||||
## Tech Stack
|
||||
- **Frontend**: TanStack (React 19, Query, Router) + Vite + Tailwind CSS + shadcn/ui
|
||||
- **Frontend**: React 19 + TanStack (Query, Router) + Vite + Tailwind CSS + shadcn/ui
|
||||
- **Backend**: NestJS + TypeORM + SQLite
|
||||
- **CMS**: Strapi (in /cms)
|
||||
|
||||
## Build Commands
|
||||
## Commands
|
||||
|
||||
### Backend (NestJS)
|
||||
### Root Level
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
npm run start:dev # Watch mode
|
||||
npm run build
|
||||
npm run start:prod
|
||||
npm run lint # Lint
|
||||
npm run lint:fix # Auto-fix
|
||||
npm run type-check # TypeScript check
|
||||
npm test # All tests
|
||||
npm test app.service.spec.ts # Single file
|
||||
npm test -t "should" # By test name
|
||||
npm run test:cov # Coverage
|
||||
npm run test:e2e # E2E tests
|
||||
npm run dev:local # Start backend & frontend locally
|
||||
npm run dev:docker # Start via docker-compose
|
||||
npm run lint # Lint both projects
|
||||
npm run lint:fix # Auto-fix lint issues
|
||||
npm run type-check # Type check both projects
|
||||
```
|
||||
|
||||
### Frontend (TanStack)
|
||||
### Backend (cd backend)
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # Vite dev server
|
||||
npm run build
|
||||
npm run preview
|
||||
npm run lint
|
||||
npm run lint:fix
|
||||
npm run type-check
|
||||
npm test # All tests
|
||||
npm test Header.test.tsx # Single file
|
||||
npm test -t "renders" # By name
|
||||
npm run test:ui # Vitest UI
|
||||
npm run start:dev # Watch mode
|
||||
npm run lint / lint:fix # ESLint
|
||||
npm run type-check # TypeScript check
|
||||
npm test # All tests
|
||||
npm test app.service.spec # Single file (omit .ts)
|
||||
npm test -t "should return" # By pattern
|
||||
npm test:cov # Coverage
|
||||
npm test:e2e # E2E tests
|
||||
```
|
||||
|
||||
### Frontend (cd frontend)
|
||||
```bash
|
||||
npm run dev # Vite dev server
|
||||
npm run lint / lint:fix # ESLint
|
||||
npm run type-check # TypeScript check
|
||||
npm test # All tests (Vitest)
|
||||
npm test Header.test # Single file (omit .tsx)
|
||||
npm test -t "renders" # By pattern
|
||||
npm test:ui # Vitest UI
|
||||
npm test:coverage # Coverage
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
### Formatting & TypeScript
|
||||
- Use Prettier (2 spaces, single quotes, trailing commas, 100 char max)
|
||||
- Strict TypeScript - no implicit `any`, avoid `any`, use `unknown`
|
||||
### TypeScript
|
||||
- Strict mode: `noImplicitAny`, `strictNullChecks`, `noFallthroughCasesInSwitch`
|
||||
- No implicit `any` - use `unknown` when type uncertain
|
||||
- Explicit return types for public methods
|
||||
- Prefer interfaces over types for objects
|
||||
- Prefer `interface` over `type` for objects
|
||||
- Use `readonly` for immutable properties
|
||||
|
||||
### Naming Conventions
|
||||
- **Files**: kebab-case (`user-profile.ts`, `auth.service.ts`)
|
||||
- **Classes**: PascalCase (`UserService`, `AuthGuard`)
|
||||
- **Functions/Variables**: camelCase (`getUser`, `isLoading`)
|
||||
- **Constants**: UPPER_SNAKE_CASE (`MAX_RETRIES`)
|
||||
- **Private members**: underscore prefix (`_cache`, `_validate()`)
|
||||
- **Interfaces**: PascalCase, no 'I' prefix (`User`, `ApiResponse`)
|
||||
### Formatting (Prettier)
|
||||
- Single quotes, trailing commas, 2-space indent
|
||||
- No semicolons in comments
|
||||
|
||||
### Import Order
|
||||
- External libraries first, then internal modules, then relative imports
|
||||
### Naming
|
||||
| Element | Convention | Example |
|
||||
|---------|------------|---------|
|
||||
| Files | kebab-case | `user-profile.ts`, `auth.service.ts` |
|
||||
| Classes | PascalCase | `UserService`, `AuthGuard` |
|
||||
| Functions/Variables | camelCase | `getUser`, `isLoading` |
|
||||
| Constants | UPPER_SNAKE_CASE | `MAX_RETRIES` |
|
||||
| Private members | underscore prefix | `_cache`, `_validate()` |
|
||||
| Interfaces | PascalCase (no I prefix) | `User`, `ApiResponse` |
|
||||
|
||||
### Imports
|
||||
Group with blank lines: External → Internal → Relative
|
||||
```typescript
|
||||
// External
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
// Internal modules
|
||||
import { UserService } from '../users/user.service';
|
||||
|
||||
// Relative
|
||||
import { AuthResponse } from './types';
|
||||
```
|
||||
|
||||
### File Structure
|
||||
```
|
||||
backend/src/
|
||||
modules/feature-name/
|
||||
feature-name.module.ts
|
||||
feature-name.controller.ts
|
||||
feature-name.service.ts
|
||||
feature-name.entity.ts
|
||||
feature-name.dto.ts
|
||||
modules/{feature}/
|
||||
{feature}.module.ts
|
||||
{feature}.controller.ts
|
||||
{feature}.service.ts
|
||||
{feature}.dto.ts
|
||||
common/{decorators,filters,guards,interceptors,pipes}
|
||||
config/
|
||||
entities.ts # All TypeORM entities
|
||||
|
||||
frontend/src/
|
||||
components/{ui,layout,features}/
|
||||
hooks/
|
||||
lib/
|
||||
queries/
|
||||
routes/
|
||||
types/
|
||||
utils/
|
||||
components/{ui,layout,features,admin,routes}/
|
||||
hooks/ # Custom React hooks
|
||||
queries/ # TanStack Query hooks
|
||||
lib/ # Utilities, API client
|
||||
routes/ # TanStack Router routes
|
||||
types/ # TypeScript types
|
||||
```
|
||||
|
||||
### Path Aliases
|
||||
- Frontend: `@/*` maps to `./src/*`
|
||||
- Import UI components: `import { Button } from '@/components/ui/button'`
|
||||
|
||||
## Patterns
|
||||
|
||||
### Backend Services (NestJS)
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private userService: UserService,
|
||||
private jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async login(dto: LoginUserDto): Promise<AuthResponse> {
|
||||
// Implementation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend Hooks (TanStack Query)
|
||||
```typescript
|
||||
export function useArticles(params: FindArticlesParams = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['articles', params],
|
||||
queryFn: () => api.fetchArticles(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateArticle() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: api.createArticle,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
- **Backend**: Use NestJS exceptions (`NotFoundException`, `BadRequestException`), custom exceptions, Logger
|
||||
- **Backend**: NestJS exceptions (`NotFoundException`, `BadRequestException`)
|
||||
- **Frontend**: Error boundaries, try-catch with user-friendly messages
|
||||
|
||||
### API Conventions
|
||||
- REST endpoints: `/api/v1/resources`
|
||||
- JSON fields: snake_case
|
||||
- Pagination: `page`, `limit` query params
|
||||
- Response format:
|
||||
```typescript
|
||||
{
|
||||
data: T,
|
||||
meta: { total, page, limit },
|
||||
error?: { code, message }
|
||||
}
|
||||
```
|
||||
### API Response Format
|
||||
```typescript
|
||||
{ data: T, meta: { total, page, limit }, error?: { code, message } }
|
||||
```
|
||||
|
||||
### Testing
|
||||
- Unit tests for services/hooks (AAA pattern: Arrange-Act-Assert)
|
||||
- Integration tests for API endpoints
|
||||
- E2E tests for critical user flows
|
||||
- Descriptive test names that explain what is tested
|
||||
- Mock external dependencies (use Jest for backend, Vitest for frontend)
|
||||
- Run `npm test` for all tests or target specific files/names
|
||||
## Testing
|
||||
- **Backend**: Jest (`*.spec.ts`), AAA pattern, mock dependencies
|
||||
- **Frontend**: Vitest + React Testing Library (`*.test.tsx`)
|
||||
- Descriptive names: `should return user data when valid ID provided`
|
||||
|
||||
### Git Workflow
|
||||
- Conventional commits: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`
|
||||
- Keep commits atomic and focused
|
||||
- Write clear commit messages in imperative mood
|
||||
|
||||
### Component Guidelines
|
||||
- Keep components small and focused (< 200 lines)
|
||||
- Use functional components with hooks
|
||||
- Prefer composition over inheritance
|
||||
- Extract complex logic into custom hooks
|
||||
- Use TanStack Query for server state management
|
||||
- Use TanStack Router for routing
|
||||
- Validate props with TypeScript
|
||||
|
||||
### Database
|
||||
- Use TypeORM for SQLite
|
||||
- Define entities with proper relationships
|
||||
- Use migrations for schema changes
|
||||
- Seed data with factory functions
|
||||
- Use TypeORM repositories for data access
|
||||
|
||||
### UI Components
|
||||
- Use shadcn/ui components from `components/ui/`
|
||||
- Use class-variance-authority (CVA) for component variants
|
||||
- Use clsx and tailwind-merge for class composition
|
||||
- Follow shadcn patterns for new component creation
|
||||
|
||||
|
||||
## Environment Variables
|
||||
- Backend: `@nestjs/config` with `.env` files
|
||||
- Frontend: Vite env vars (`VITE_API_URL`)
|
||||
- Never commit secrets to the repository.
|
||||
- Example frontend env: `VITE_API_URL=http://localhost:3000`
|
||||
- Example backend env: `DATABASE_PATH=./data.db`
|
||||
- Store sensitive config in `.env` files
|
||||
- Use `DATABASE_PATH` for SQLite location
|
||||
## Git Commits
|
||||
- Conventional: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`
|
||||
- Atomic, imperative mood
|
||||
|
||||
## UI Components
|
||||
- Use shadcn/ui from `components/ui/`
|
||||
- CVA for variants, clsx + tailwind-merge for classes
|
||||
|
||||
## Agent Instructions
|
||||
- **ALWAYS** run `npm run lint` and `npm run type-check` after changes
|
||||
- **NEVER** commit without explicit request
|
||||
- **PREFER** editing existing files over creating new ones
|
||||
- **VERIFY** tests pass before considering work complete
|
||||
- **CHECK** both backend/frontend when making API changes
|
||||
|
||||
28
COOLIFY_ENV_VARS.txt
Normal file
28
COOLIFY_ENV_VARS.txt
Normal file
@ -0,0 +1,28 @@
|
||||
# ===========================================
|
||||
# COOLIFY ENVIRONMENT VARIABLES
|
||||
# Copy these to Coolify UI: Environment Variables section
|
||||
# ===========================================
|
||||
|
||||
# Database Password (you should already have this set)
|
||||
DATABASE_PASSWORD=abreubre776677112233
|
||||
|
||||
# Backend JWT Secret (REQUIRED - MISSING!)
|
||||
JWT_SECRET=Xt4mwkbFEw83dSMXCv6W0Ut6YoTgIkYO62eticw0CfxkZhYSplDbjeUOyrnwyWK34Pt3nrnmtE5+khKxeoHiSA==
|
||||
|
||||
# Strapi API Token (leave empty for now - will set after making API public)
|
||||
STRAPI_API_TOKEN=
|
||||
|
||||
# Strapi Security Keys (REQUIRED)
|
||||
STRAPI_APP_KEYS=Jlt1Pu+ZBzcTSYazU8ZlEMOZyj4F9MO9YVAJmkaKrnk=,V2VSvJQrZ61jk8MtVkhC2RrKcm3XvJzmYTi73NItPYQ=,dhvlKrjeYGbCGaZznVTEJZLcAjIIwAtNmT6/i5Zq09I=,qDCKh9Pdep3P4ZlX+OCsKUwj/VZKul959RGbxXdiyf8=
|
||||
STRAPI_API_TOKEN_SALT=MkkvTfDJkwEPznUVfbiKj3SSWPw/MKqrOIRxN9cyWLk=
|
||||
STRAPI_ADMIN_JWT_SECRET=RpqNlR20k4VF2x1rzRvjUsg46zN2X4YcfBowbjdvqJo=
|
||||
STRAPI_TRANSFER_TOKEN_SALT=96PznECGwwinWXB8fhlHwE11+0XU5TaJwTaztQPaQw4=
|
||||
STRAPI_JWT_SECRET=3CGgFzvM8ykfndK2pqcCb7U5W3FcBF0SXwalj1kby6s=
|
||||
STRAPI_ENCRYPTION_KEY=8V99V0CfSxJZLvgXGRBv/zndKH2FnPQ/JVmXa1OEfZ8=
|
||||
|
||||
# Push Notification VAPID Keys (for PWA notifications)
|
||||
VAPID_PUBLIC_KEY=BEUVi1YA6wyD1Mt31M8nbsz7ctVC1wxURkz4bHdrexbtUzDETS90MOpS-QFebnXt_Dx_zvntPHCno6bwsK3pOxU
|
||||
VAPID_PRIVATE_KEY=XJIYJyV1KkfEwnHa1vy4Jb3FPRg27eFton1Tdsep8fI
|
||||
|
||||
# VAPID Subject (optional - defaults to mailto:contact@placebo.mk)
|
||||
VAPID_SUBJECT=mailto:contact@placebo.mk
|
||||
721
DEPLOYMENT.md
Normal file
721
DEPLOYMENT.md
Normal file
@ -0,0 +1,721 @@
|
||||
# Placebo.mk Deployment Guide (Coolify)
|
||||
|
||||
Complete deployment guide for the Placebo.mk satirical news platform on a VPS using Coolify.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Architecture Overview](#architecture-overview)
|
||||
2. [Prerequisites](#prerequisites)
|
||||
3. [Coolify Setup](#coolify-setup)
|
||||
4. [Database Configuration](#database-configuration)
|
||||
5. [Service Deployments](#service-deployments)
|
||||
6. [Environment Variables Reference](#environment-variables-reference)
|
||||
7. [Domain & SSL Configuration](#domain--ssl-configuration)
|
||||
8. [Push Notifications Setup](#push-notifications-setup)
|
||||
9. [Post-Deployment Checklist](#post-deployment-checklist)
|
||||
10. [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ Coolify Reverse Proxy │
|
||||
│ (Traefik/Caddy) │
|
||||
└────────────────┬─────────────────────┘
|
||||
│
|
||||
┌────────────────────────────────┼────────────────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Frontend │ │ PWA │ │ CMS │
|
||||
│ (React+Vite) │ │ (React+Vite+SW) │ │ (Strapi) │
|
||||
│ nginx:80 │ │ nginx:80 │ │ Node.js:1337 │
|
||||
│ placebo.mk │ │ app.placebo.mk │ │ cms.placebo.mk │
|
||||
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
└────────────────────────────────┼────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Backend │
|
||||
│ (NestJS) │
|
||||
│ Node.js:3000 │
|
||||
│ api.placebo.mk │
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ PostgreSQL │
|
||||
│ :5432 │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Service | Technology | Port | Purpose |
|
||||
|---------|------------|------|---------|
|
||||
| Frontend | React 19 + Vite + Tailwind | 80 | Main website (SSG) |
|
||||
| PWA | React 19 + Vite + Workbox | 80 | Progressive Web App with push |
|
||||
| Backend | NestJS + TypeORM | 3000 | REST API |
|
||||
| CMS | Strapi 5 | 1337 | Content management |
|
||||
| Database | PostgreSQL 16 | 5432 | Primary data store |
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Server Requirements
|
||||
|
||||
| Resource | Minimum | Recommended |
|
||||
|----------|---------|-------------|
|
||||
| RAM | 4GB | 8GB |
|
||||
| CPU | 2 vCPU | 4 vCPU |
|
||||
| Storage | 40GB SSD | 80GB SSD |
|
||||
| OS | Ubuntu 22.04 | Ubuntu 24.04 |
|
||||
|
||||
### Domain Setup
|
||||
|
||||
Configure these DNS A records pointing to your VPS IP:
|
||||
|
||||
| Type | Name | Purpose |
|
||||
|------|------|---------|
|
||||
| A | `@` | Main website (frontend) |
|
||||
| A | `www` | WWW redirect |
|
||||
| A | `api` | Backend API |
|
||||
| A | `app` | PWA |
|
||||
| A | `cms` | Strapi admin |
|
||||
|
||||
### Required Before Deployment
|
||||
|
||||
- [ ] Coolify installed and accessible
|
||||
- [ ] Domain DNS configured
|
||||
- [ ] GitHub repository connected to Coolify
|
||||
- [ ] SMTP server (optional, for emails)
|
||||
|
||||
---
|
||||
|
||||
## Coolify Setup
|
||||
|
||||
### 1. Create Project
|
||||
|
||||
1. Login to Coolify dashboard
|
||||
2. Navigate to **Projects**
|
||||
3. Click **+ New Project**
|
||||
4. Name: `placebo`
|
||||
5. Click **Create**
|
||||
|
||||
### 2. Create Environment
|
||||
|
||||
1. Inside `placebo` project
|
||||
2. Click **+ New Environment**
|
||||
3. Name: `production`
|
||||
4. Click **Create**
|
||||
|
||||
### 3. Connect Repository
|
||||
|
||||
1. Go to **Configuration** → **Source**
|
||||
2. Click **Connect GitHub**
|
||||
3. Authorize Coolify to access your repositories
|
||||
4. Select repository: `your-org/placeboMk`
|
||||
|
||||
---
|
||||
|
||||
## Quick Deploy (Docker Compose)
|
||||
|
||||
Deploy all services in one step using Docker Compose.
|
||||
|
||||
### Step 1: Create Service
|
||||
|
||||
1. In `production` environment, click **+ New Resource**
|
||||
2. Select **Docker Compose** (not Dockerfile)
|
||||
3. Name: `placebo-stack`
|
||||
4. Repository: Select your connected repo
|
||||
5. Compose File: `docker-compose.coolify.yml`
|
||||
|
||||
### Step 2: Set Environment Variables
|
||||
|
||||
Click **Environment Variables** and add these secrets:
|
||||
|
||||
| Variable | How to Generate |
|
||||
|----------|-----------------|
|
||||
| `DATABASE_PASSWORD` | `openssl rand -base64 32` |
|
||||
| `JWT_SECRET` | `openssl rand -base64 64 \| tr -d '\n'` |
|
||||
| `VAPID_PUBLIC_KEY` | Run `npm run generate-vapid` locally |
|
||||
| `VAPID_PRIVATE_KEY` | Run `npm run generate-vapid` locally |
|
||||
| `STRAPI_APP_KEYS` | See generation command below |
|
||||
| `STRAPI_API_TOKEN_SALT` | `openssl rand -base64 32` |
|
||||
| `STRAPI_ADMIN_JWT_SECRET` | `openssl rand -base64 32` |
|
||||
| `STRAPI_TRANSFER_TOKEN_SALT` | `openssl rand -base64 32` |
|
||||
| `STRAPI_JWT_SECRET` | `openssl rand -base64 32` |
|
||||
| `STRAPI_API_TOKEN` | Get from CMS admin after first deploy |
|
||||
|
||||
**Generate Strapi APP_KEYS** (4 comma-separated keys):
|
||||
```bash
|
||||
for i in {1..4}; do node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"; done | paste -sd "," -
|
||||
```
|
||||
|
||||
**Generate VAPID Keys** (run locally):
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
npm run generate-vapid
|
||||
```
|
||||
|
||||
### Step 3: Configure Domains
|
||||
|
||||
The compose file uses Traefik labels with these domains:
|
||||
- `placebo.mk` - Frontend
|
||||
- `www.placebo.mk` - Frontend (redirect)
|
||||
- `api.placebo.mk` - Backend API
|
||||
- `app.placebo.mk` - PWA
|
||||
- `cms.placebo.mk` - CMS (Strapi)
|
||||
|
||||
Ensure DNS is configured before deploying.
|
||||
|
||||
### Step 4: Deploy
|
||||
|
||||
1. Click **Deploy**
|
||||
2. Wait for all services to start (2-5 minutes)
|
||||
3. Check logs if any service fails
|
||||
|
||||
### Step 5: Create CMS Admin
|
||||
|
||||
1. Visit `https://cms.placebo.mk/admin`
|
||||
2. Create your admin user
|
||||
3. Generate API token for backend
|
||||
4. Add `STRAPI_API_TOKEN` to environment variables
|
||||
5. Redeploy
|
||||
|
||||
---
|
||||
|
||||
## Manual Deploy (Individual Services)
|
||||
|
||||
If you prefer deploying each service separately, follow these steps.
|
||||
|
||||
---
|
||||
|
||||
## Database Configuration
|
||||
|
||||
### Create PostgreSQL Service
|
||||
|
||||
1. In `production` environment, click **+ New Resource**
|
||||
2. Select **Database** → **PostgreSQL**
|
||||
3. Configure:
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Name | `placebo-db` |
|
||||
| Version | `16-alpine` |
|
||||
| PostgreSQL User | `placebo_user` |
|
||||
| PostgreSQL Password | `<generate-secure-password>` |
|
||||
| PostgreSQL Database | `placebo_db` |
|
||||
| Persistent Volume | Enabled |
|
||||
|
||||
4. Click **Deploy`
|
||||
|
||||
### Note Connection Details
|
||||
|
||||
After deployment, note the internal hostname:
|
||||
```
|
||||
Host: placebo-db
|
||||
Port: 5432
|
||||
Database: placebo_db
|
||||
Username: placebo_user
|
||||
Password: <your-password>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service Deployments (Manual)
|
||||
|
||||
### 1. Backend (NestJS API)
|
||||
|
||||
#### Create Service
|
||||
|
||||
1. Click **+ New Resource** → **Service**
|
||||
2. Select **Dockerfile** (not Docker Compose)
|
||||
3. Name: `placebo-backend`
|
||||
4. Repository: Select your connected repo
|
||||
|
||||
#### Configuration
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Dockerfile Location | `backend/Dockerfile` |
|
||||
| Build Context | `backend` |
|
||||
| Port | `3000` |
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
```env
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
|
||||
# Database
|
||||
DATABASE_TYPE=postgres
|
||||
DATABASE_HOST=placebo-db
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_USERNAME=placebo_user
|
||||
DATABASE_PASSWORD=<your-db-password>
|
||||
DATABASE_NAME=placebo_db
|
||||
DATABASE_SYNCHRONIZE=false
|
||||
DATABASE_LOGGING=false
|
||||
|
||||
# JWT Authentication
|
||||
JWT_SECRET=<generate-64-char-secret>
|
||||
JWT_EXPIRATION=86400
|
||||
|
||||
# CORS
|
||||
CORS_ORIGIN=https://placebo.mk,https://www.placebo.mk,https://app.placebo.mk
|
||||
|
||||
# Strapi CMS
|
||||
STRAPI_URL=https://cms.placebo.mk
|
||||
STRAPI_API_TOKEN=<your-strapi-api-token>
|
||||
|
||||
# Push Notifications
|
||||
VAPID_SUBJECT=mailto:contact@placebo.mk
|
||||
VAPID_PUBLIC_KEY=<generated-public-key>
|
||||
VAPID_PRIVATE_KEY=<generated-private-key>
|
||||
```
|
||||
|
||||
#### Generate Secrets
|
||||
|
||||
**JWT Secret:**
|
||||
```bash
|
||||
openssl rand -base64 64 | tr -d '\n'
|
||||
```
|
||||
|
||||
**VAPID Keys** (run locally):
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
npm run generate-vapid
|
||||
```
|
||||
|
||||
#### Health Check
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Path | `/health` |
|
||||
| Interval | 30s |
|
||||
| Timeout | 10s |
|
||||
| Retries | 3 |
|
||||
|
||||
#### Domain
|
||||
|
||||
1. Go to **Domains** tab
|
||||
2. Add: `api.placebo.mk`
|
||||
3. Enable **HTTPS** (Let's Encrypt)
|
||||
|
||||
---
|
||||
|
||||
### 2. Frontend (Main Website)
|
||||
|
||||
#### Create Service
|
||||
|
||||
1. Click **+ New Resource** → **Service** → **Dockerfile**
|
||||
2. Name: `placebo-frontend`
|
||||
|
||||
#### Configuration
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Dockerfile Location | `frontend/Dockerfile` |
|
||||
| Build Context | `frontend` |
|
||||
| Port | `80` |
|
||||
|
||||
#### Build Arguments
|
||||
|
||||
Vite requires environment variables at build time. Add these as **Build Args**:
|
||||
|
||||
```env
|
||||
VITE_API_URL=https://api.placebo.mk/api/v1
|
||||
VITE_CMS_URL=https://cms.placebo.mk
|
||||
```
|
||||
|
||||
#### Domain
|
||||
|
||||
1. Add: `placebo.mk`
|
||||
2. Add: `www.placebo.mk`
|
||||
3. Enable **HTTPS**
|
||||
|
||||
---
|
||||
|
||||
### 3. PWA (Progressive Web App)
|
||||
|
||||
#### Create Service
|
||||
|
||||
1. Click **+ New Resource** → **Service** → **Dockerfile**
|
||||
2. Name: `placebo-pwa`
|
||||
|
||||
#### Configuration
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Dockerfile Location | `pwa/Dockerfile` |
|
||||
| Build Context | `pwa` |
|
||||
| Port | `80` |
|
||||
|
||||
#### Build Arguments
|
||||
|
||||
```env
|
||||
VITE_API_URL=https://api.placebo.mk/api/v1
|
||||
VITE_CMS_URL=https://cms.placebo.mk
|
||||
```
|
||||
|
||||
#### Persistent Volume (Optional)
|
||||
|
||||
For service worker and cache:
|
||||
- Container Path: `/usr/share/nginx/html`
|
||||
- Host Path: `pwa-dist`
|
||||
|
||||
#### Domain
|
||||
|
||||
1. Add: `app.placebo.mk`
|
||||
2. Enable **HTTPS**
|
||||
|
||||
---
|
||||
|
||||
### 4. CMS (Strapi)
|
||||
|
||||
#### Create Service
|
||||
|
||||
1. Click **+ New Resource** → **Service** → **Dockerfile**
|
||||
2. Name: `placebo-cms`
|
||||
|
||||
#### Configuration
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Dockerfile Location | `cms/cms/Dockerfile` |
|
||||
| Build Context | `cms/cms` |
|
||||
| Port | `1337` |
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
```env
|
||||
NODE_ENV=production
|
||||
HOST=0.0.0.0
|
||||
PORT=1337
|
||||
|
||||
# Database
|
||||
DATABASE_CLIENT=postgres
|
||||
DATABASE_HOST=placebo-db
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_NAME=placebo_db
|
||||
DATABASE_USERNAME=placebo_user
|
||||
DATABASE_PASSWORD=<your-db-password>
|
||||
DATABASE_SSL=false
|
||||
|
||||
# Security Keys (generate unique values for each)
|
||||
APP_KEYS=<generate-4-keys>
|
||||
API_TOKEN_SALT=<32-char-secret>
|
||||
ADMIN_JWT_SECRET=<32-char-secret>
|
||||
TRANSFER_TOKEN_SALT=<32-char-secret>
|
||||
JWT_SECRET=<32-char-secret>
|
||||
|
||||
# CORS
|
||||
CORS_ORIGIN=https://placebo.mk,https://app.placebo.mk,https://api.placebo.mk
|
||||
```
|
||||
|
||||
#### Generate Strapi Keys
|
||||
|
||||
**For APP_KEYS** (4 comma-separated keys):
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
# Run 4 times, join with commas
|
||||
```
|
||||
|
||||
**For other secrets:**
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
#### Persistent Volumes
|
||||
|
||||
Add these volumes for data persistence:
|
||||
|
||||
| Container Path | Purpose |
|
||||
|----------------|---------|
|
||||
| `/app/public/uploads` | Media uploads |
|
||||
| `/app/.tmp` | Temporary files |
|
||||
|
||||
#### Domain
|
||||
|
||||
1. Add: `cms.placebo.mk`
|
||||
2. Enable **HTTPS**
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
### Backend
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `NODE_ENV` | Yes | Set to `production` |
|
||||
| `DATABASE_HOST` | Yes | PostgreSQL hostname |
|
||||
| `DATABASE_PORT` | Yes | PostgreSQL port (5432) |
|
||||
| `DATABASE_USERNAME` | Yes | Database username |
|
||||
| `DATABASE_PASSWORD` | Yes | Database password |
|
||||
| `DATABASE_NAME` | Yes | Database name |
|
||||
| `DATABASE_SYNCHRONIZE` | Yes | Set to `false` in production |
|
||||
| `JWT_SECRET` | Yes | 64+ character secret |
|
||||
| `CORS_ORIGIN` | Yes | Comma-separated allowed origins |
|
||||
| `VAPID_PUBLIC_KEY` | Yes | For push notifications |
|
||||
| `VAPID_PRIVATE_KEY` | Yes | For push notifications |
|
||||
| `STRAPI_URL` | Yes | CMS URL |
|
||||
| `STRAPI_API_TOKEN` | Yes | API token from Strapi |
|
||||
|
||||
### Frontend/PWA (Build Args)
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `VITE_API_URL` | Yes | Backend API URL |
|
||||
| `VITE_CMS_URL` | Yes | CMS URL |
|
||||
|
||||
### CMS (Strapi)
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `APP_KEYS` | Yes | 4 comma-separated 32-byte keys |
|
||||
| `API_TOKEN_SALT` | Yes | Random 32-byte string |
|
||||
| `ADMIN_JWT_SECRET` | Yes | Random 32-byte string |
|
||||
| `JWT_SECRET` | Yes | Random 32-byte string |
|
||||
| `TRANSFER_TOKEN_SALT` | Yes | Random 32-byte string |
|
||||
|
||||
---
|
||||
|
||||
## Domain & SSL Configuration
|
||||
|
||||
### Configure Each Service
|
||||
|
||||
For each deployed service:
|
||||
|
||||
1. Go to service → **Configuration** → **Domains**
|
||||
2. Click **Add Domain**
|
||||
3. Enter domain (e.g., `api.placebo.mk`)
|
||||
4. Enable **HTTPS**
|
||||
5. Select **Let's Encrypt**
|
||||
6. Click **Request Certificate**
|
||||
|
||||
### SSL Certificate Generation
|
||||
|
||||
Coolify automatically handles SSL via Let's Encrypt. Ensure:
|
||||
- DNS is properly configured
|
||||
- Port 80 and 443 are open
|
||||
- Domain is accessible from internet
|
||||
|
||||
---
|
||||
|
||||
## Push Notifications Setup
|
||||
|
||||
### 1. Generate VAPID Keys
|
||||
|
||||
Run locally in the backend directory:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
npm run generate-vapid
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Generating VAPID keys...
|
||||
VAPID keys generated and added to .env file:
|
||||
Public Key: BIAna-ulT88Ek5ZdiAiXJikks3T5JEuZjBstuO7bEbUJ6RAoSmXAbum1Pf7JIbo-YQfwXM1Yi-rGTttOuNJUpUE
|
||||
Private Key: DGtRu68SmAxng-xx3BiTCa9NrztQbsSoECRuzry9VD4
|
||||
```
|
||||
|
||||
### 2. Configure Backend
|
||||
|
||||
Add to backend environment variables:
|
||||
```env
|
||||
VAPID_SUBJECT=mailto:contact@placebo.mk
|
||||
VAPID_PUBLIC_KEY=<your-public-key>
|
||||
VAPID_PRIVATE_KEY=<your-private-key>
|
||||
```
|
||||
|
||||
### 3. Verify Setup
|
||||
|
||||
1. Deploy backend with VAPID keys
|
||||
2. Visit: `https://api.placebo.mk/api/v1/push/vapid-public-key`
|
||||
3. Should return JSON with your public key
|
||||
|
||||
### 4. Test in PWA
|
||||
|
||||
1. Open `https://app.placebo.mk`
|
||||
2. Wait for notification banner
|
||||
3. Click "Вклучи" (Enable)
|
||||
4. Accept browser permission
|
||||
5. Verify subscription in admin dashboard
|
||||
|
||||
---
|
||||
|
||||
## Post-Deployment Checklist
|
||||
|
||||
### Backend
|
||||
|
||||
- [ ] Health check passing: `https://api.placebo.mk/health`
|
||||
- [ ] API docs accessible: `https://api.placebo.mk/api/v1`
|
||||
- [ ] Database connected (check logs)
|
||||
- [ ] JWT authentication working
|
||||
- [ ] CORS accepting frontend origins
|
||||
- [ ] Push notification endpoint working
|
||||
|
||||
### Frontend
|
||||
|
||||
- [ ] Site loads: `https://placebo.mk`
|
||||
- [ ] No console errors
|
||||
- [ ] API calls successful (check Network tab)
|
||||
- [ ] Images and assets loading
|
||||
- [ ] Navigation working correctly
|
||||
- [ ] Admin dashboard accessible
|
||||
|
||||
### PWA
|
||||
|
||||
- [ ] Site loads: `https://app.placebo.mk`
|
||||
- [ ] Service worker registered (DevTools → Application)
|
||||
- [ ] Install prompt appears
|
||||
- [ ] Notification banner shows
|
||||
- [ ] Push subscription successful
|
||||
- [ ] Works offline (cached resources)
|
||||
|
||||
### CMS
|
||||
|
||||
- [ ] Admin panel: `https://cms.placebo.mk/admin`
|
||||
- [ ] First admin user created
|
||||
- [ ] API token generated for backend
|
||||
- [ ] Media uploads working
|
||||
- [ ] Content types created
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Backend Won't Start
|
||||
|
||||
**Symptoms:** Container keeps restarting
|
||||
|
||||
**Solutions:**
|
||||
1. Check logs: Coolify → Service → Logs
|
||||
2. Verify database connection:
|
||||
- `DATABASE_HOST` should be service name (e.g., `placebo-db`)
|
||||
- Check password matches database password
|
||||
3. Verify all required env vars are set
|
||||
4. Check JWT_SECRET is at least 32 characters
|
||||
|
||||
### Frontend Shows Blank Page
|
||||
|
||||
**Symptoms:** Page loads but nothing displays
|
||||
|
||||
**Solutions:**
|
||||
1. Check browser console for errors
|
||||
2. Verify build args were set:
|
||||
- `VITE_API_URL` must be set at build time
|
||||
3. Check Network tab for failed API calls
|
||||
4. Rebuild with correct environment variables
|
||||
|
||||
### CORS Errors
|
||||
|
||||
**Symptoms:** Browser blocks API requests
|
||||
|
||||
**Solutions:**
|
||||
1. Add all frontend domains to `CORS_ORIGIN`:
|
||||
```env
|
||||
CORS_ORIGIN=https://placebo.mk,https://www.placebo.mk,https://app.placebo.mk
|
||||
```
|
||||
2. Restart backend after changing
|
||||
3. Clear browser cache
|
||||
|
||||
### Push Notifications Not Working
|
||||
|
||||
**Symptoms:** Subscription fails or notifications don't arrive
|
||||
|
||||
**Solutions:**
|
||||
1. Verify HTTPS is enabled (required for push)
|
||||
2. Check VAPID keys are set in backend
|
||||
3. Test endpoint: `/api/v1/push/vapid-public-key`
|
||||
4. Check service worker is registered
|
||||
5. Verify browser notification permission is granted
|
||||
|
||||
### Database Connection Issues
|
||||
|
||||
**Symptoms:** Backend can't connect to database
|
||||
|
||||
**Solutions:**
|
||||
1. Verify database service is running
|
||||
2. Check hostname matches database service name
|
||||
3. Verify credentials are correct
|
||||
4. Ensure both services are on the same network
|
||||
5. Check database logs for errors
|
||||
|
||||
### CMS Admin Not Accessible
|
||||
|
||||
**Symptoms:** 404 or redirect loop on `/admin`
|
||||
|
||||
**Solutions:**
|
||||
1. Verify all Strapi secrets are set
|
||||
2. Check `HOST=0.0.0.0` is set
|
||||
3. Clear browser cache and cookies
|
||||
4. Check CMS logs for startup errors
|
||||
|
||||
---
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Updating Deployments
|
||||
|
||||
1. Push changes to GitHub main branch
|
||||
2. Coolify → Service → **Redeploy**
|
||||
3. Or enable automatic deployments on push
|
||||
|
||||
### Database Backups
|
||||
|
||||
1. Coolify → Database → **Backups**
|
||||
2. Enable scheduled backups
|
||||
3. Recommended retention: 7-30 days
|
||||
4. Store backups off-server periodically
|
||||
|
||||
### Log Management
|
||||
|
||||
- **View logs:** Coolify → Service → Logs
|
||||
- **Download logs:** Available in service settings
|
||||
- **Log rotation:** Configured automatically
|
||||
|
||||
### Monitoring
|
||||
|
||||
1. Set up Coolify notifications for:
|
||||
- Service down
|
||||
- High resource usage
|
||||
- Deployment failures
|
||||
2. Monitor disk usage (uploads, logs)
|
||||
3. Check SSL certificate expiration
|
||||
|
||||
---
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] All secrets are 32+ characters
|
||||
- [ ] HTTPS enabled on all services
|
||||
- [ ] Database not exposed to internet
|
||||
- [ ] CORS restricted to your domains only
|
||||
- [ ] CMS admin protected (consider IP whitelist)
|
||||
- [ ] Regular security updates applied
|
||||
- [ ] Backups configured and tested
|
||||
- [ ] Monitoring and alerts enabled
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For issues specific to:
|
||||
- **Coolify**: [Coolify Documentation](https://coolify.io/docs)
|
||||
- **NestJS**: [NestJS Documentation](https://docs.nestjs.com)
|
||||
- **Strapi**: [Strapi Documentation](https://docs.strapi.io)
|
||||
- **Vite/PWA**: [Vite PWA Plugin](https://vite-pwa-org.netlify.app)
|
||||
441
DOCKER-README.md
Normal file
441
DOCKER-README.md
Normal file
@ -0,0 +1,441 @@
|
||||
# Placebo.mk Docker Setup
|
||||
|
||||
## Overview
|
||||
Complete Docker configuration for Placebo.mk - a Macedonian news site with sarcastic tone. Includes both development and production setups.
|
||||
|
||||
## Architecture
|
||||
- **Frontend**: TanStack (React 19) + Vite + Tailwind CSS
|
||||
- **Backend**: NestJS + TypeORM
|
||||
- **CMS**: Strapi
|
||||
- **Database**: PostgreSQL (production), SQLite (development)
|
||||
- **Reverse Proxy**: Nginx (production)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Development Environment (Hot Reload)
|
||||
```bash
|
||||
# Start development environment
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
|
||||
# Services available at:
|
||||
# Frontend: http://localhost:5173
|
||||
# Backend API: http://localhost:3000
|
||||
# CMS Admin: http://localhost:1337/admin
|
||||
# PostgreSQL: localhost:5432
|
||||
```
|
||||
|
||||
### Production Environment (Local Testing)
|
||||
```bash
|
||||
# Start production environment
|
||||
docker-compose up -d
|
||||
|
||||
# Services available at:
|
||||
# Frontend: http://localhost:3001
|
||||
# Backend API: http://localhost:3000
|
||||
# CMS: http://localhost:1337
|
||||
```
|
||||
|
||||
### Test Everything
|
||||
```bash
|
||||
# Run comprehensive test
|
||||
chmod +x scripts/test-docker.sh
|
||||
./scripts/test-docker.sh
|
||||
```
|
||||
|
||||
## Docker Files Structure
|
||||
|
||||
### Production Dockerfiles
|
||||
- `backend/Dockerfile` - NestJS API with Node.js 20 Alpine
|
||||
- `frontend/Dockerfile` - React app built with Vite, served by Nginx
|
||||
- `cms/cms/Dockerfile` - Strapi CMS with PostgreSQL support
|
||||
- `frontend/nginx.conf` - Nginx configuration for frontend
|
||||
|
||||
### Development Dockerfiles
|
||||
- `backend/Dockerfile.dev` - Development with hot reload
|
||||
- `frontend/Dockerfile.dev` - Development with Vite dev server
|
||||
- `cms/cms/Dockerfile.dev` - Strapi development mode
|
||||
|
||||
### Docker Compose Files
|
||||
- `docker-compose.yml` - Production setup with all services
|
||||
- `docker-compose.dev.yml` - Development setup with volume mounts
|
||||
|
||||
## Services
|
||||
|
||||
### 1. PostgreSQL Database
|
||||
- **Image**: `postgres:16-alpine`
|
||||
- **Port**: 5432
|
||||
- **Database**: `placebo_db`
|
||||
- **User**: `placebo_user`
|
||||
- **Password**: `placebo_password`
|
||||
- **Volume**: `postgres_data` (persistent storage)
|
||||
|
||||
### 2. Backend API (NestJS)
|
||||
- **Production Port**: 3000
|
||||
- **Development Port**: 3000 (hot reload)
|
||||
- **Health Check**: `GET /health`
|
||||
- **Environment**: See `backend/.env.example`
|
||||
|
||||
### 3. Frontend (TanStack React)
|
||||
- **Production Port**: 3001 (via Nginx)
|
||||
- **Development Port**: 5173 (Vite dev server)
|
||||
- **Build Tool**: Vite
|
||||
- **Environment**: See `frontend/.env.example`
|
||||
|
||||
### 4. CMS (Strapi)
|
||||
- **Port**: 1337
|
||||
- **Admin**: `/admin`
|
||||
- **Health Check**: `GET /_health`
|
||||
- **Environment**: See `cms/cms/.env.example`
|
||||
|
||||
### 5. Nginx (Production Only)
|
||||
- **Port**: 80
|
||||
- **Configuration**: `frontend/nginx.conf`
|
||||
- **Features**: SSL, compression, security headers, React Router support
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Backend (.env)
|
||||
```bash
|
||||
NODE_ENV=production
|
||||
DATABASE_TYPE=postgres
|
||||
DATABASE_HOST=postgres
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_USERNAME=placebo_user
|
||||
DATABASE_PASSWORD=placebo_password
|
||||
DATABASE_NAME=placebo_db
|
||||
JWT_SECRET=your-jwt-secret-key-change-in-production
|
||||
CORS_ORIGIN=http://localhost:5173,http://localhost:3001
|
||||
```
|
||||
|
||||
### Frontend (.env)
|
||||
```bash
|
||||
NODE_ENV=production
|
||||
VITE_API_URL=http://localhost:3000
|
||||
VITE_CMS_URL=http://localhost:1337
|
||||
```
|
||||
|
||||
### CMS (.env)
|
||||
```bash
|
||||
NODE_ENV=production
|
||||
DATABASE_CLIENT=postgres
|
||||
DATABASE_HOST=postgres
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_NAME=placebo_db
|
||||
DATABASE_USERNAME=placebo_user
|
||||
DATABASE_PASSWORD=placebo_password
|
||||
JWT_SECRET=your-jwt-secret-key-change-in-production
|
||||
```
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Development
|
||||
```bash
|
||||
# Start development environment
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
|
||||
# View logs
|
||||
docker-compose -f docker-compose.dev.yml logs -f
|
||||
|
||||
# Stop development
|
||||
docker-compose -f docker-compose.dev.yml down
|
||||
|
||||
# Rebuild development images
|
||||
docker-compose -f docker-compose.dev.yml build
|
||||
```
|
||||
|
||||
### Production (Local Testing)
|
||||
```bash
|
||||
# Start production environment
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Stop production
|
||||
docker-compose down
|
||||
|
||||
# Rebuild production images
|
||||
docker-compose build
|
||||
```
|
||||
|
||||
### Database Operations
|
||||
```bash
|
||||
# Access PostgreSQL shell
|
||||
docker-compose exec postgres psql -U placebo_user -d placebo_db
|
||||
|
||||
# Backup database
|
||||
docker-compose exec postgres pg_dump -U placebo_user placebo_db > backup.sql
|
||||
|
||||
# Restore database
|
||||
docker-compose exec -T postgres psql -U placebo_user placebo_db < backup.sql
|
||||
```
|
||||
|
||||
### Service Management
|
||||
```bash
|
||||
# Restart specific service
|
||||
docker-compose restart backend
|
||||
docker-compose restart frontend
|
||||
docker-compose restart cms
|
||||
|
||||
# View service status
|
||||
docker-compose ps
|
||||
|
||||
# View resource usage
|
||||
docker-compose stats
|
||||
```
|
||||
|
||||
## Health Checks
|
||||
|
||||
All services include health checks:
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
curl http://localhost:3000/health
|
||||
```
|
||||
|
||||
### CMS
|
||||
```bash
|
||||
curl http://localhost:1337/_health
|
||||
```
|
||||
|
||||
### Frontend
|
||||
```bash
|
||||
curl http://localhost:3001
|
||||
```
|
||||
|
||||
### PostgreSQL
|
||||
```bash
|
||||
docker-compose exec postgres pg_isready -U placebo_user -d placebo_db
|
||||
```
|
||||
|
||||
## Database Migration
|
||||
|
||||
### SQLite to PostgreSQL Migration
|
||||
Detailed migration plan in `scripts/migrate-to-postgres.md`
|
||||
|
||||
```bash
|
||||
# Export SQLite data
|
||||
sqlite3 backend/data.db .dump > backend-data.sql
|
||||
|
||||
# Transform for PostgreSQL
|
||||
python scripts/transform-sqlite-to-postgres.py backend-data.sql > backend-postgres.sql
|
||||
|
||||
# Import to PostgreSQL
|
||||
docker-compose exec -T postgres psql -U placebo_user placebo_db < backend-postgres.sql
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. Port Conflicts
|
||||
```bash
|
||||
# Check what's using port 3000
|
||||
sudo lsof -i :3000
|
||||
|
||||
# Or use different ports in docker-compose.yml
|
||||
```
|
||||
|
||||
#### 2. Docker Build Failures
|
||||
```bash
|
||||
# Clear Docker cache
|
||||
docker system prune -a
|
||||
|
||||
# Rebuild with no cache
|
||||
docker-compose build --no-cache
|
||||
```
|
||||
|
||||
#### 3. Database Connection Issues
|
||||
```bash
|
||||
# Check PostgreSQL logs
|
||||
docker-compose logs postgres
|
||||
|
||||
# Test connection from backend
|
||||
docker-compose exec backend node -e "console.log('Testing DB connection...')"
|
||||
```
|
||||
|
||||
#### 4. Permission Issues
|
||||
```bash
|
||||
# Fix volume permissions
|
||||
sudo chown -R $USER:$USER .
|
||||
|
||||
# Rebuild containers
|
||||
docker-compose down && docker-compose up -d
|
||||
```
|
||||
|
||||
### Debug Commands
|
||||
```bash
|
||||
# Shell into container
|
||||
docker-compose exec backend sh
|
||||
docker-compose exec frontend sh
|
||||
docker-compose exec cms sh
|
||||
|
||||
# View container details
|
||||
docker-compose exec backend env
|
||||
docker-compose exec frontend env
|
||||
|
||||
# Check network connectivity
|
||||
docker-compose exec backend curl http://postgres:5432
|
||||
docker-compose exec frontend curl http://backend:3000/health
|
||||
```
|
||||
|
||||
## Deployment to Coolify
|
||||
|
||||
### 1. Prepare for Production
|
||||
```bash
|
||||
# Update environment variables for production
|
||||
cp backend/.env.example backend/.env.production
|
||||
cp frontend/.env.example frontend/.env.production
|
||||
cp cms/cms/.env.example cms/cms/.env.production
|
||||
|
||||
# Build production images
|
||||
docker-compose build
|
||||
|
||||
# Test locally
|
||||
docker-compose up -d
|
||||
./scripts/test-docker.sh
|
||||
```
|
||||
|
||||
### 2. Coolify Configuration
|
||||
1. Connect GitHub repository to Coolify
|
||||
2. Create three applications:
|
||||
- Frontend (from `/frontend`)
|
||||
- Backend (from `/backend`)
|
||||
- CMS (from `/cms/cms`)
|
||||
3. Configure environment variables
|
||||
4. Set up PostgreSQL database
|
||||
5. Configure domains and SSL
|
||||
|
||||
### 3. Database Migration
|
||||
1. Export production SQLite data
|
||||
2. Transform for PostgreSQL
|
||||
3. Import to Coolify PostgreSQL
|
||||
4. Verify data integrity
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Frontend
|
||||
- Nginx gzip compression enabled
|
||||
- Static asset caching (1 year)
|
||||
- Security headers configured
|
||||
- React Router support
|
||||
|
||||
### Backend
|
||||
- Health checks every 30 seconds
|
||||
- Connection pooling with PostgreSQL
|
||||
- CORS configured for frontend domains
|
||||
|
||||
### Database
|
||||
- PostgreSQL connection pooling
|
||||
- Regular backups via volumes
|
||||
- Health checks with `pg_isready`
|
||||
|
||||
## Security
|
||||
|
||||
### Implemented Security Features
|
||||
1. **Non-root users**: All containers run as non-root
|
||||
2. **Health checks**: Automatic service monitoring
|
||||
3. **Environment variables**: Secrets stored in .env files
|
||||
4. **Network isolation**: Services on internal network
|
||||
5. **Security headers**: X-Frame-Options, CSP, etc.
|
||||
6. **SSL ready**: Nginx configured for HTTPS
|
||||
|
||||
### Security Best Practices
|
||||
1. Never commit `.env` files to Git
|
||||
2. Use strong passwords for PostgreSQL
|
||||
3. Regular security updates
|
||||
4. Monitor Docker logs
|
||||
5. Backup database regularly
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Built-in Monitoring
|
||||
```bash
|
||||
# View container logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Check container status
|
||||
docker-compose ps
|
||||
|
||||
# Monitor resource usage
|
||||
docker-compose stats
|
||||
|
||||
# View health check status
|
||||
docker inspect --format='{{.State.Health.Status}}' placebo-backend
|
||||
```
|
||||
|
||||
### Custom Monitoring
|
||||
Add to `docker-compose.yml`:
|
||||
```yaml
|
||||
monitoring:
|
||||
image: prom/prometheus
|
||||
ports:
|
||||
- "9090:9090"
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
```
|
||||
|
||||
## Backup and Recovery
|
||||
|
||||
### Database Backup
|
||||
```bash
|
||||
# Daily backup script
|
||||
docker-compose exec postgres pg_dump -U placebo_user placebo_db > backup-$(date +%Y%m%d).sql
|
||||
|
||||
# Compress backup
|
||||
gzip backup-$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
### Volume Backup
|
||||
```bash
|
||||
# Backup PostgreSQL volume
|
||||
docker run --rm -v postgres_data:/source -v $(pwd)/backups:/backup alpine tar czf /backup/postgres-$(date +%Y%m%d).tar.gz -C /source .
|
||||
```
|
||||
|
||||
### Recovery
|
||||
```bash
|
||||
# Restore database
|
||||
docker-compose exec -T postgres psql -U placebo_user placebo_db < backup.sql
|
||||
|
||||
# Restore volume
|
||||
docker run --rm -v postgres_data:/target -v $(pwd)/backups:/backup alpine tar xzf /backup/postgres-backup.tar.gz -C /target
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
### Adding New Services
|
||||
1. Create Dockerfile in service directory
|
||||
2. Add service to `docker-compose.yml`
|
||||
3. Configure environment variables
|
||||
4. Add health check
|
||||
5. Test locally
|
||||
6. Update documentation
|
||||
|
||||
### Updating Dependencies
|
||||
```bash
|
||||
# Rebuild with updated dependencies
|
||||
docker-compose build --no-cache
|
||||
|
||||
# Update package.json in mounted volumes
|
||||
docker-compose exec backend npm update
|
||||
docker-compose exec frontend npm update
|
||||
docker-compose exec cms npm update
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
### Documentation
|
||||
- `DEPLOYMENT.md` - Complete deployment guide
|
||||
- `scripts/migrate-to-postgres.md` - Database migration plan
|
||||
- `AGENTS.md` - Development guidelines
|
||||
|
||||
### Troubleshooting Resources
|
||||
- Docker Documentation: https://docs.docker.com
|
||||
- PostgreSQL Documentation: https://www.postgresql.org/docs
|
||||
- Strapi Documentation: https://docs.strapi.io
|
||||
- Coolify Documentation: https://coolify.io/docs
|
||||
|
||||
### Macedonian Resources
|
||||
- Local hosting providers
|
||||
- Macedonian domain registration
|
||||
- GDPR compliance for Macedonian users
|
||||
314
PUBLISHER_GUIDE.md
Normal file
314
PUBLISHER_GUIDE.md
Normal file
@ -0,0 +1,314 @@
|
||||
# Publisher & Content Creator Guide - Placebo.mk
|
||||
|
||||
## Overview
|
||||
Placebo.mk is a Macedonian news site with a sarcastic tone. This guide covers content creation, publishing workflows, and best practices for writers and editors.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Accessing the CMS
|
||||
1. Navigate to: `http://your-domain.com/admin`
|
||||
2. Log in with your credentials
|
||||
3. Select "Content Manager" from the sidebar
|
||||
|
||||
### Your Dashboard
|
||||
- **Content Manager**: Create and edit articles
|
||||
- **Media Library**: Upload and manage images/files
|
||||
- **Profile**: Update your author information
|
||||
|
||||
## Content Creation Workflow
|
||||
|
||||
### 1. Planning Your Article
|
||||
Before writing:
|
||||
- Choose a compelling headline
|
||||
- Research your topic thoroughly
|
||||
- Gather supporting images/media
|
||||
- Plan your article structure
|
||||
|
||||
### 2. Creating a New Article
|
||||
|
||||
#### Basic Article Setup
|
||||
1. Click **+ Create new entry** in Content Manager
|
||||
2. Select **Article** content type
|
||||
3. Fill in required fields:
|
||||
- **Title**: Catchy, SEO-friendly headline
|
||||
- **Author**: Your name (auto-populated)
|
||||
- **Content**: Main article body
|
||||
|
||||
#### Article Fields Explained
|
||||
- **Title**: Maximum 100 characters, include keywords
|
||||
- **Content**: Rich text editor with formatting options
|
||||
- **Media**: Upload images, videos, documents
|
||||
- **Featured Image**: Main article thumbnail
|
||||
- **Status**: Draft → Published → Archived
|
||||
|
||||
### 3. Writing Best Practices
|
||||
|
||||
#### Headline Guidelines
|
||||
- Use active voice and strong verbs
|
||||
- Include location if relevant (e.g., "Скопје:")
|
||||
- Keep under 100 characters
|
||||
- Ask questions or make bold statements
|
||||
|
||||
#### Content Structure
|
||||
```
|
||||
1. Lead Paragraph (2-3 sentences)
|
||||
- Who, what, where, when, why
|
||||
- Most important information first
|
||||
|
||||
2. Body Paragraphs (3-5 paragraphs)
|
||||
- Supporting details and quotes
|
||||
- Background information
|
||||
- Analysis and context
|
||||
|
||||
3. Conclusion (1-2 paragraphs)
|
||||
- Summary or call to action
|
||||
- Future implications
|
||||
```
|
||||
|
||||
#### Writing Style
|
||||
- **Tone**: Sarcastic but informative
|
||||
- **Language**: Macedonian with local context
|
||||
- **Length**: 300-800 words for news, 800-1500 for features
|
||||
- **Voice**: Conversational, engaging, slightly cynical
|
||||
|
||||
### 4. Media Integration
|
||||
|
||||
#### Adding Images
|
||||
1. Click **Media** field in article editor
|
||||
2. Click **Add new** or select from library
|
||||
3. Drag and drop or browse files
|
||||
4. Add alt text for accessibility
|
||||
5. Choose image size and alignment
|
||||
|
||||
#### Image Guidelines
|
||||
- **Featured Image**: 1200x630px minimum
|
||||
- **Inline Images**: 800px width maximum
|
||||
- **File Size**: Under 2MB per image
|
||||
- **Formats**: JPG, PNG, WebP
|
||||
|
||||
#### Video and Audio
|
||||
- Upload MP4 videos (under 100MB)
|
||||
- Embed YouTube/Vimeo links
|
||||
- Add MP3 audio files for interviews
|
||||
|
||||
## Publishing Workflow
|
||||
|
||||
### Article Status Management
|
||||
|
||||
#### Draft Status
|
||||
- Initial creation phase
|
||||
- Auto-saves every 30 seconds
|
||||
- Only visible to you and editors
|
||||
|
||||
#### Review Process
|
||||
1. Submit for review when ready
|
||||
2. Editor reviews content and style
|
||||
3. Request changes or approve
|
||||
4. Editor may edit directly
|
||||
|
||||
#### Published Status
|
||||
- Live on the website
|
||||
- Publicly accessible
|
||||
- Appears in category listings
|
||||
- Indexed by search engines
|
||||
|
||||
#### Archived Status
|
||||
- No longer public
|
||||
- Retained for reference
|
||||
- Can be republished if needed
|
||||
|
||||
### Publishing Best Practices
|
||||
|
||||
#### Before Publishing
|
||||
- [ ] Proofread for spelling/grammar
|
||||
- [ ] Check all facts and sources
|
||||
- [ ] Test all links
|
||||
- [ ] Optimize images for web
|
||||
- [ ] Add relevant tags
|
||||
- [ ] Set appropriate category
|
||||
|
||||
#### Publishing Schedule
|
||||
- **Breaking News**: Publish immediately
|
||||
- **Features**: Schedule for peak traffic (10:00-14:00)
|
||||
- **Analysis**: Publish by 17:00 for evening readers
|
||||
|
||||
## Live Blogging
|
||||
|
||||
### When to Use Live Blogs
|
||||
- Ongoing events (protests, sports, conferences)
|
||||
- Developing news stories
|
||||
- Election coverage
|
||||
- Emergency situations
|
||||
|
||||
### Creating a Live Blog
|
||||
1. Navigate to **Live Blogs** in Content Manager
|
||||
2. Click **+ Create new entry**
|
||||
3. Fill in basic information:
|
||||
- **Title**: Event name + "LIVE"
|
||||
- **Description**: Brief event summary
|
||||
- **Status**: Set to "Live" when ready
|
||||
|
||||
### Live Blog Updates
|
||||
Each update should include:
|
||||
- **Timestamp**: Auto-generated
|
||||
- **Content**: New information (1-3 sentences)
|
||||
- **Author**: Your attribution
|
||||
- **Media**: Relevant photos/videos
|
||||
|
||||
#### Update Guidelines
|
||||
- Keep updates concise and factual
|
||||
- Post major developments immediately
|
||||
- Use quotes when available
|
||||
- Add context for new followers
|
||||
- Pin important updates
|
||||
|
||||
## Content Categories
|
||||
|
||||
### Available Categories
|
||||
- **Политика**: Government, elections, policy
|
||||
- **Економија**: Business, markets, finance
|
||||
- **Друштво**: Social issues, culture, lifestyle
|
||||
- **Спорт**: Sports news and events
|
||||
- **Технологија**: Tech news and innovations
|
||||
- **Свет**: International news
|
||||
- **Култура**: Arts, entertainment, events
|
||||
|
||||
### Category Selection
|
||||
- Choose primary category carefully
|
||||
- Use tags for additional context
|
||||
- Consider audience interests
|
||||
- Follow editorial calendar
|
||||
|
||||
## SEO and Discoverability
|
||||
|
||||
### Title Optimization
|
||||
- Include primary keywords
|
||||
- Use location when relevant
|
||||
- Ask questions or make statements
|
||||
- Avoid clickbait (maintain credibility)
|
||||
|
||||
### Content SEO
|
||||
- Naturally include keywords
|
||||
- Use header tags (H2, H3)
|
||||
- Add internal links to related articles
|
||||
- Include external sources when relevant
|
||||
|
||||
### Meta Information
|
||||
- **Excerpt**: 150-character summary
|
||||
- **Featured Image**: Optimized for social sharing
|
||||
- **Tags**: 3-5 relevant keywords
|
||||
|
||||
## Author Profile Management
|
||||
|
||||
### Updating Your Profile
|
||||
1. Click **Profile** in sidebar
|
||||
2. Update information:
|
||||
- **Display Name**: Your byline name
|
||||
- **Bio**: 100-word professional summary
|
||||
- **Avatar**: Professional headshot
|
||||
- **Social Links**: Twitter, LinkedIn, etc.
|
||||
|
||||
### Author Best Practices
|
||||
- Maintain consistent voice across articles
|
||||
- Build expertise in specific topics
|
||||
- Engage with reader comments
|
||||
- Share published articles on social media
|
||||
|
||||
## Content Moderation
|
||||
|
||||
### Comment Policy
|
||||
- Comments are enabled on published articles
|
||||
- Inappropriate content is filtered automatically
|
||||
- Editors can approve/reject comments manually
|
||||
|
||||
### Handling Corrections
|
||||
1. Mark errors immediately
|
||||
2. Correct factual mistakes
|
||||
3. Add correction note at top
|
||||
4. Notify editor of significant changes
|
||||
|
||||
## Analytics and Performance
|
||||
|
||||
### Article Metrics
|
||||
- **Views**: Total page reads
|
||||
- **Engagement**: Time on page, scroll depth
|
||||
- **Shares**: Social media interactions
|
||||
- **Comments**: Reader engagement
|
||||
|
||||
### Improving Performance
|
||||
- Write compelling headlines
|
||||
- Use relevant, high-quality images
|
||||
- Publish at optimal times
|
||||
- Share on social media
|
||||
- Engage with reader comments
|
||||
|
||||
## Tools and Shortcuts
|
||||
|
||||
### Editor Shortcuts
|
||||
- **Ctrl+B**: Bold text
|
||||
- **Ctrl+I**: Italic text
|
||||
- **Ctrl+K**: Insert link
|
||||
- **Ctrl+Z**: Undo
|
||||
- **Ctrl+Y**: Redo
|
||||
|
||||
### Media Management
|
||||
- Drag and drop uploads
|
||||
- Bulk image optimization
|
||||
- Automatic alt text suggestions
|
||||
- Image cropping and resizing
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Editor Not Saving
|
||||
- Check internet connection
|
||||
- Refresh page and try again
|
||||
- Contact administrator if persistent
|
||||
|
||||
#### Image Upload Errors
|
||||
- Verify file size under 2MB
|
||||
- Check file format (JPG, PNG, WebP)
|
||||
- Clear browser cache and retry
|
||||
|
||||
#### Publishing Problems
|
||||
- Ensure all required fields completed
|
||||
- Check article status permissions
|
||||
- Contact editor for approval
|
||||
|
||||
## Best Practices Summary
|
||||
|
||||
### Do's
|
||||
- Write factually accurate content
|
||||
- Use engaging, sarcastic tone appropriately
|
||||
- Include relevant, high-quality media
|
||||
- Proofread thoroughly before publishing
|
||||
- Engage with reader feedback
|
||||
- Follow ethical journalism standards
|
||||
|
||||
### Don'ts
|
||||
- Publish unverified information
|
||||
- Use excessive clickbait
|
||||
- Ignore reader comments
|
||||
- Publish without editor review
|
||||
- Violate copyright laws
|
||||
- Share confidential information
|
||||
|
||||
## Support and Resources
|
||||
|
||||
### Getting Help
|
||||
- **Editor**: Primary contact for content questions
|
||||
- **Administrator**: Technical support and system issues
|
||||
- **Style Guide**: Detailed writing and formatting guidelines
|
||||
|
||||
### Training Resources
|
||||
- CMS video tutorials
|
||||
- Writing style workshops
|
||||
- SEO best practices guide
|
||||
- Social media sharing strategies
|
||||
|
||||
### Community
|
||||
- Regular writer meetings
|
||||
- Content planning sessions
|
||||
- Feedback and improvement discussions
|
||||
- Collaboration opportunities
|
||||
70
QUICK_WEBHOOK_SETUP.md
Normal file
70
QUICK_WEBHOOK_SETUP.md
Normal file
@ -0,0 +1,70 @@
|
||||
# Quick Strapi Webhook Setup
|
||||
|
||||
## TL;DR - Quick Configuration
|
||||
|
||||
1. **Access Strapi Admin**: `http://localhost:1337/admin`
|
||||
2. **Go to Settings → Webhooks**
|
||||
3. **Add New Webhook**:
|
||||
- **Name**: `Backend Sync`
|
||||
- **URL**: `http://localhost:3000/api/v1/webhooks/strapi`
|
||||
- **Events**: Select all for "Article" and "Live Blog" content types
|
||||
4. **Save and Test**
|
||||
|
||||
## Already Configured (What We Fixed)
|
||||
|
||||
✅ **Backend webhook endpoints** are now public (no auth required)
|
||||
✅ **Tested webhooks** manually - they work
|
||||
✅ **Articles sync** from Strapi to backend
|
||||
✅ **Frontend TypeScript errors** fixed
|
||||
✅ **Authentication system** working
|
||||
|
||||
## Manual Test Commands
|
||||
|
||||
```bash
|
||||
# Test webhook manually
|
||||
curl -X POST http://localhost:3000/api/v1/webhooks/strapi \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"event": "entry.publish",
|
||||
"model": "article",
|
||||
"entry": {"documentId": "r07qatlpgvx82d7337n3nz1l"}
|
||||
}'
|
||||
|
||||
# Check synced articles
|
||||
curl http://localhost:3000/api/v1/articles?status=published
|
||||
|
||||
# Run comprehensive test
|
||||
./scripts/test-webhooks.sh
|
||||
```
|
||||
|
||||
## Current Status
|
||||
|
||||
- **Strapi Articles**: 2
|
||||
- **Backend Articles**: 2 (synced)
|
||||
- **Webhook Status**: Ready for configuration
|
||||
- **Frontend**: Access at `http://localhost:5173/articles`
|
||||
|
||||
## Immediate Action Required
|
||||
|
||||
1. **Configure webhooks in Strapi admin** (see detailed guide in `STRAPI_WEBHOOKS_SETUP.md`)
|
||||
2. **Test by publishing an article** in Strapi
|
||||
3. **Verify automatic sync** works
|
||||
|
||||
## If Webhooks Don't Work
|
||||
|
||||
Use manual sync as fallback:
|
||||
```bash
|
||||
# Get auth token first
|
||||
TOKEN=$(curl -s -X POST http://localhost:3000/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"testuser","password":"Test123!"}' | jq -r '.access_token')
|
||||
|
||||
# Sync all articles
|
||||
curl -X POST http://localhost:3000/api/v1/webhooks/strapi/sync/all \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
## Verification
|
||||
- Visit `http://localhost:5173/articles` to see synced articles
|
||||
- Check backend logs: `docker logs placebo-backend-dev --tail 20`
|
||||
- Monitor sync status with test script: `./scripts/test-webhooks.sh`
|
||||
188
README-DEVELOPMENT.md
Normal file
188
README-DEVELOPMENT.md
Normal file
@ -0,0 +1,188 @@
|
||||
# Placebo.mk - Development Setup
|
||||
|
||||
This project supports two development modes:
|
||||
1. **Docker Mode** (Recommended): All services run in Docker containers
|
||||
2. **Local Mode**: Services run locally with npm, PostgreSQL in Docker
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1: Docker Mode (Recommended)
|
||||
```bash
|
||||
# Start all services in Docker
|
||||
npm run docker:up
|
||||
|
||||
# Or use the alias
|
||||
npm run dev:docker
|
||||
```
|
||||
|
||||
Services will be available at:
|
||||
- Frontend: http://localhost:5173
|
||||
- Backend API: http://localhost:3000
|
||||
- CMS (Strapi): http://localhost:1337/admin
|
||||
- PostgreSQL: localhost:5432
|
||||
|
||||
### Option 2: Local Mode
|
||||
```bash
|
||||
# Start PostgreSQL in Docker
|
||||
docker-compose -f docker-compose.dev.yml up postgres
|
||||
|
||||
# In another terminal, start backend and frontend locally
|
||||
npm run dev:local
|
||||
```
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Backend Configuration
|
||||
The backend uses `.env` files for configuration:
|
||||
|
||||
- `.env` - Default Docker configuration (committed)
|
||||
- `.env.local` - Local development configuration (not committed)
|
||||
- `.env.docker` - Docker configuration template
|
||||
- `.env.example` - Example configuration
|
||||
|
||||
**Switching between modes:**
|
||||
```bash
|
||||
# Switch to local mode
|
||||
cd backend && npm run dev:local
|
||||
|
||||
# Switch back to Docker mode
|
||||
cd backend && npm run dev:reset-env
|
||||
```
|
||||
|
||||
### Frontend Configuration
|
||||
The frontend also uses `.env` files:
|
||||
|
||||
- `.env` - Default Docker configuration (committed)
|
||||
- `.env.local` - Local development configuration (not committed)
|
||||
- `.env.docker` - Docker configuration template
|
||||
|
||||
**API URL differences:**
|
||||
- Docker: `VITE_API_URL=http://backend:3000/api/v1`
|
||||
- Local: `VITE_API_URL=http://localhost:3000/api/v1`
|
||||
|
||||
## Development Scripts
|
||||
|
||||
### Root Level Scripts
|
||||
```bash
|
||||
# Docker commands
|
||||
npm run docker:up # Start all services
|
||||
npm run docker:down # Stop all services
|
||||
npm run docker:build # Rebuild containers
|
||||
npm run docker:logs # View logs
|
||||
npm run docker:ps # Check container status
|
||||
|
||||
# Development modes
|
||||
npm run dev:docker # Docker mode (alias for docker:up)
|
||||
npm run dev:local # Local mode
|
||||
|
||||
# Utility
|
||||
npm run reset:env # Reset .env files to Docker defaults
|
||||
npm run lint # Run linting on all services
|
||||
npm run type-check # Run TypeScript checks
|
||||
```
|
||||
|
||||
### Backend Scripts (in backend/ directory)
|
||||
```bash
|
||||
npm run dev:docker # Start in Docker mode (uses .env)
|
||||
npm run dev:local # Start in local mode (uses .env.local)
|
||||
npm run start:dev # Original NestJS dev command
|
||||
npm run dev:reset-env # Reset .env to Docker defaults
|
||||
```
|
||||
|
||||
### Frontend Scripts (in frontend/ directory)
|
||||
```bash
|
||||
npm run dev:docker # Start in Docker mode
|
||||
npm run dev:local # Start in local mode
|
||||
npm run dev # Original Vite dev command
|
||||
npm run dev:reset-env # Reset .env to Docker defaults
|
||||
```
|
||||
|
||||
## Database Management
|
||||
|
||||
### PostgreSQL in Docker
|
||||
The PostgreSQL container is configured with:
|
||||
- Host: `localhost:5432` (from host) or `postgres:5432` (from Docker)
|
||||
- Database: `placebo_backend_db` (backend), `placebo_cms_db` (CMS)
|
||||
- User: `placebo_user`
|
||||
- Password: `placebo_password`
|
||||
|
||||
### Connecting to Database
|
||||
```bash
|
||||
# From host machine
|
||||
psql -h localhost -p 5432 -U placebo_user -d placebo_backend_db
|
||||
|
||||
# From within Docker container
|
||||
psql -h postgres -p 5432 -U placebo_user -d placebo_backend_db
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
placeboMk/
|
||||
├── backend/ # NestJS backend API
|
||||
│ ├── .env # Docker configuration (default)
|
||||
│ ├── .env.local # Local development configuration
|
||||
│ └── src/
|
||||
├── frontend/ # React/Vite frontend
|
||||
│ ├── .env # Docker configuration (default)
|
||||
│ ├── .env.local # Local development configuration
|
||||
│ └── src/
|
||||
├── cms/cms/ # Strapi CMS
|
||||
├── docker-compose.yml # Production configuration
|
||||
├── docker-compose.dev.yml # Development configuration
|
||||
└── package.json # Root scripts
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Port conflicts**
|
||||
- Check if ports 3000, 5173, 1337, 5432 are free
|
||||
- Use `lsof -i :3000` to check what's using a port
|
||||
|
||||
2. **Database connection errors**
|
||||
- Ensure PostgreSQL container is running: `docker ps`
|
||||
- Check logs: `docker logs placebo-postgres-dev`
|
||||
- Verify credentials in `.env` files
|
||||
|
||||
3. **Environment configuration**
|
||||
- Run `npm run reset:env` to reset to Docker defaults
|
||||
- Ensure `.env.local` exists for local development
|
||||
|
||||
4. **Docker container issues**
|
||||
- Rebuild containers: `npm run docker:build`
|
||||
- Clear Docker cache: `docker system prune -f`
|
||||
|
||||
### Reset Everything
|
||||
```bash
|
||||
# Stop and remove containers
|
||||
npm run docker:down
|
||||
docker system prune -f
|
||||
|
||||
# Reset environment files
|
||||
npm run reset:env
|
||||
|
||||
# Start fresh
|
||||
npm run docker:up
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
For production, use:
|
||||
```bash
|
||||
docker-compose -f docker-compose.yml up -d
|
||||
```
|
||||
|
||||
Production configuration:
|
||||
- Uses separate `docker-compose.yml`
|
||||
- Includes Nginx reverse proxy
|
||||
- No hot reload, optimized builds
|
||||
- Health checks and proper logging
|
||||
|
||||
## Notes
|
||||
|
||||
- **Docker-first approach**: `.env` files are configured for Docker by default
|
||||
- **Local development**: Uses `.env.local` files when running services locally
|
||||
- **PostgreSQL**: Always runs in Docker for consistency
|
||||
- **Secrets**: Never commit actual secrets to repository
|
||||
191
STRAPI_WEBHOOKS_SETUP.md
Normal file
191
STRAPI_WEBHOOKS_SETUP.md
Normal file
@ -0,0 +1,191 @@
|
||||
# Strapi Webhooks Configuration Guide
|
||||
|
||||
## Overview
|
||||
This guide explains how to configure Strapi webhooks for automatic synchronization with the backend API. When articles or live blogs are published/updated/deleted in Strapi, webhooks will automatically trigger synchronization with the backend.
|
||||
|
||||
## Webhook Endpoints
|
||||
|
||||
The backend provides three webhook endpoints:
|
||||
|
||||
1. **Article-specific webhook**: `POST /api/v1/webhooks/strapi/article`
|
||||
2. **Live Blog-specific webhook**: `POST /api/v1/webhooks/strapi/live-blog`
|
||||
3. **Generic webhook**: `POST /api/v1/webhooks/strapi` (handles both)
|
||||
|
||||
All endpoints accept the following JSON format:
|
||||
```json
|
||||
{
|
||||
"event": "entry.publish", // or "entry.update", "entry.delete", "entry.unpublish"
|
||||
"model": "article", // or "live-blog"
|
||||
"entry": {
|
||||
"documentId": "unique-strapi-id"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration Steps
|
||||
|
||||
### Step 1: Access Strapi Admin Panel
|
||||
1. Open your browser and go to: `http://localhost:1337/admin`
|
||||
2. Log in with your admin credentials
|
||||
|
||||
### Step 2: Configure Webhooks
|
||||
1. In the left sidebar, click on **Settings** (gear icon)
|
||||
2. Click on **Webhooks** under the "GLOBAL SETTINGS" section
|
||||
3. Click the **"Add new webhook"** button
|
||||
|
||||
### Step 3: Configure Article Webhook
|
||||
**Basic Settings:**
|
||||
- **Name**: `Backend Article Sync`
|
||||
- **URL**: `http://localhost:3000/api/v1/webhooks/strapi/article`
|
||||
- For Docker internal communication: `http://backend:3000/api/v1/webhooks/strapi/article`
|
||||
- **Headers**: Add if needed (usually not required)
|
||||
|
||||
**Trigger Events:**
|
||||
Select the following events for the "Article" content type:
|
||||
- [x] **Create entry** (`entry.create`)
|
||||
- [x] **Update entry** (`entry.update`)
|
||||
- [x] **Delete entry** (`entry.delete`)
|
||||
- [x] **Publish entry** (`entry.publish`)
|
||||
- [x] **Unpublish entry** (`entry.unpublish`)
|
||||
|
||||
**Save the webhook.**
|
||||
|
||||
### Step 4: Configure Live Blog Webhook (Optional)
|
||||
Repeat Step 3 for live blogs:
|
||||
- **Name**: `Backend Live Blog Sync`
|
||||
- **URL**: `http://localhost:3000/api/v1/webhooks/strapi/live-blog`
|
||||
- Select events for the "Live Blog" content type
|
||||
|
||||
### Alternative: Single Generic Webhook
|
||||
You can use a single webhook for all content types:
|
||||
- **Name**: `Backend Generic Sync`
|
||||
- **URL**: `http://localhost:3000/api/v1/webhooks/strapi`
|
||||
- Select events for ALL relevant content types (Article, Live Blog)
|
||||
|
||||
## Testing Webhooks
|
||||
|
||||
### Manual Test
|
||||
You can manually trigger a webhook to test the configuration:
|
||||
|
||||
```bash
|
||||
# Test article webhook
|
||||
curl -X POST http://localhost:3000/api/v1/webhooks/strapi/article \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"event": "entry.publish",
|
||||
"model": "article",
|
||||
"entry": {
|
||||
"documentId": "your-article-id"
|
||||
}
|
||||
}'
|
||||
|
||||
# Test generic webhook
|
||||
curl -X POST http://localhost:3000/api/v1/webhooks/strapi \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"event": "entry.publish",
|
||||
"model": "article",
|
||||
"entry": {
|
||||
"documentId": "your-article-id"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Test from Strapi
|
||||
1. In Strapi admin, edit any article
|
||||
2. Click "Save" or "Publish"
|
||||
3. Check backend logs for webhook receipt:
|
||||
```bash
|
||||
docker logs placebo-backend-dev --tail 20 | grep -i "webhook\|strapi"
|
||||
```
|
||||
|
||||
## Manual Sync (Fallback)
|
||||
If webhooks fail or for initial sync, use manual sync endpoints:
|
||||
|
||||
```bash
|
||||
# Get authentication token first
|
||||
curl -X POST http://localhost:3000/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"your-username","password":"your-password"}'
|
||||
|
||||
# Sync all articles (requires auth)
|
||||
curl -X POST http://localhost:3000/api/v1/webhooks/strapi/sync/all \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
|
||||
# Sync all live blogs
|
||||
curl -X POST http://localhost:3000/api/v1/webhooks/strapi/sync/live-blogs \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
|
||||
# Sync everything
|
||||
curl -X POST http://localhost:3000/api/v1/webhooks/strapi/sync/everything \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Webhook not received by backend**
|
||||
- Check backend logs: `docker logs placebo-backend-dev --tail 50`
|
||||
- Verify CORS is configured (should allow `http://localhost:1337`)
|
||||
- Test webhook manually with curl
|
||||
|
||||
2. **401 Unauthorized error**
|
||||
- Webhook endpoints are now public (no auth required)
|
||||
- If getting 401, check that `@Public()` decorator is on webhook methods
|
||||
|
||||
3. **Webhook received but sync fails**
|
||||
- Check Strapi API token in backend `.env.docker`
|
||||
- Verify Strapi is accessible from backend container
|
||||
- Check backend logs for specific error messages
|
||||
|
||||
4. **Article not appearing in frontend**
|
||||
- Verify article is synced to backend: `curl http://localhost:3000/api/v1/articles`
|
||||
- Check frontend browser console for errors
|
||||
- Hard refresh frontend (Ctrl+F5)
|
||||
|
||||
### Log Monitoring
|
||||
```bash
|
||||
# Monitor backend logs for webhooks
|
||||
docker logs -f placebo-backend-dev | grep -i "webhook\|strapi"
|
||||
|
||||
# Monitor Strapi logs
|
||||
docker logs -f placebo-cms-dev
|
||||
|
||||
# Check if articles are in backend
|
||||
curl -s "http://localhost:3000/api/v1/articles?status=published" | jq '.total'
|
||||
```
|
||||
|
||||
## Docker Network Considerations
|
||||
|
||||
### Internal Docker Communication
|
||||
- Within Docker network: Use `http://backend:3000` and `http://cms:1337`
|
||||
- From host machine: Use `http://localhost:3000` and `http://localhost:1337`
|
||||
|
||||
### Webhook URL Examples
|
||||
- **From Strapi container to Backend**: `http://backend:3000/api/v1/webhooks/strapi`
|
||||
- **From Host to Backend**: `http://localhost:3000/api/v1/webhooks/strapi`
|
||||
- **External/Production**: Use your domain: `https://api.yourdomain.com/api/v1/webhooks/strapi`
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Webhook endpoints are public** - ensure your backend is not exposed to the public internet in production
|
||||
2. **Consider adding webhook signature verification** for production
|
||||
3. **Use HTTPS in production** for all webhook calls
|
||||
4. **Monitor webhook logs** for suspicious activity
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] Webhooks configured in Strapi admin
|
||||
- [ ] Test webhook manually works
|
||||
- [ ] Article publish in Strapi triggers sync
|
||||
- [ ] Synced articles appear in backend API
|
||||
- [ ] Frontend displays synced articles
|
||||
- [ ] All event types tested (publish, update, delete)
|
||||
|
||||
## Support
|
||||
If issues persist:
|
||||
1. Check all service logs
|
||||
2. Verify network connectivity between containers
|
||||
3. Test each component independently
|
||||
4. Review error messages in logs
|
||||
@ -1,7 +1,34 @@
|
||||
PORT=3000
|
||||
DATABASE_PATH=./database.sqlite
|
||||
NODE_ENV=development
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
# Environment Configuration Template
|
||||
# Copy to .env and adjust values for your setup
|
||||
|
||||
# ===== DOCKER MODE (services in containers) =====
|
||||
# Use these values when running in Docker Compose
|
||||
# DATABASE_HOST=postgres
|
||||
# STRAPI_URL=http://cms:1337
|
||||
|
||||
# ===== LOCAL MODE (backend locally, DB in Docker) =====
|
||||
# Use these values when running backend locally with npm run dev
|
||||
DATABASE_HOST=localhost
|
||||
STRAPI_URL=http://localhost:1337
|
||||
|
||||
# ===== COMMON CONFIGURATION =====
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_USERNAME=placebo_user
|
||||
DATABASE_PASSWORD=placebo_password
|
||||
DATABASE_NAME=placebo_backend_db
|
||||
DATABASE_SYNCHRONIZE=true
|
||||
DATABASE_LOGGING=true
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET=dev-jwt-secret-change-in-production
|
||||
JWT_EXPIRATION=3600
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
|
||||
# Strapi CMS Configuration
|
||||
STRAPI_API_TOKEN=your-strapi-api-token-here
|
||||
|
||||
48
backend/Dockerfile
Normal file
48
backend/Dockerfile
Normal file
@ -0,0 +1,48 @@
|
||||
# Backend Dockerfile for Placebo.mk NestJS API
|
||||
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Install ALL dependencies (including devDependencies for build)
|
||||
RUN npm install --legacy-peer-deps
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build TypeScript
|
||||
RUN npm run build
|
||||
|
||||
# Prune devDependencies after build (suppress warning)
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nodejs -u 1001
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
|
||||
|
||||
# Switch to non-root user
|
||||
USER nodejs
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {if(r.statusCode !== 200) throw new Error()})"
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start application
|
||||
CMD ["node", "dist/src/main.js"]
|
||||
31
backend/Dockerfile.dev
Normal file
31
backend/Dockerfile.dev
Normal file
@ -0,0 +1,31 @@
|
||||
# Backend Development Dockerfile for Placebo.mk NestJS API
|
||||
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies for native modules
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
# Install dependencies with better error handling
|
||||
COPY package*.json ./
|
||||
COPY package-lock.json* ./
|
||||
|
||||
# Clear npm cache and install dependencies
|
||||
RUN npm cache clean --force && \
|
||||
npm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Fix permissions - use node user that exists in base image
|
||||
RUN chown -R node:node /app
|
||||
|
||||
# Switch to non-root user that exists in base image
|
||||
USER node
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start development server
|
||||
CMD ["npm", "run", "start:dev"]
|
||||
BIN
backend/database.sqlite.backup
Normal file
BIN
backend/database.sqlite.backup
Normal file
Binary file not shown.
BIN
backend/database.sqlite.old
Normal file
BIN
backend/database.sqlite.old
Normal file
Binary file not shown.
13
backend/docker-entrypoint.sh
Executable file
13
backend/docker-entrypoint.sh
Executable file
@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# Backend entrypoint script - seeds admin on first run
|
||||
|
||||
echo "Starting Placebo.mk Backend..."
|
||||
|
||||
# Run admin seed script (idempotent - won't recreate if exists)
|
||||
# Temporarily disabled to fix startup issues
|
||||
# echo "Checking for admin user..."
|
||||
# node dist/scripts/seed-admin.js || echo "Warning: Admin seed failed, continuing..."
|
||||
|
||||
# Start the application
|
||||
echo "Starting NestJS application..."
|
||||
exec node dist/src/main.js
|
||||
1269
backend/package-lock.json
generated
1269
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -19,21 +19,40 @@
|
||||
"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"
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"dev:docker": "nest start --watch",
|
||||
"dev:local": "cp -f .env.local .env && nest start --watch",
|
||||
"dev:reset-env": "cp -f .env.docker .env",
|
||||
"seed:admin": "ts-node scripts/seed-admin.ts",
|
||||
"seed:categories": "ts-node scripts/seed-categories.ts",
|
||||
"generate-vapid": "ts-node scripts/generate-vapid-keys.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/event-emitter": "^3.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"axios": "^1.13.6",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"pg": "^8.18.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"typeorm": "^0.3.28"
|
||||
"typeorm": "^0.3.28",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
@ -45,6 +64,7 @@
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
|
||||
46
backend/scripts/generate-vapid-keys.ts
Normal file
46
backend/scripts/generate-vapid-keys.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import * as webpush from 'web-push';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const envPath = path.join(__dirname, '..', '.env');
|
||||
|
||||
function generateVapidKeys(): void {
|
||||
console.log('Generating VAPID keys...');
|
||||
|
||||
const vapidKeys = webpush.generateVAPIDKeys();
|
||||
|
||||
let envContent = '';
|
||||
|
||||
if (fs.existsSync(envPath)) {
|
||||
envContent = fs.readFileSync(envPath, 'utf-8');
|
||||
}
|
||||
|
||||
const lines = envContent.split('\n');
|
||||
|
||||
const vapidSubject = `VAPID_SUBJECT=mailto:contact@placebo.mk`;
|
||||
const vapidPublicKey = `VAPID_PUBLIC_KEY=${vapidKeys.publicKey}`;
|
||||
const vapidPrivateKey = `VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`;
|
||||
|
||||
const updatedLines = lines.filter(
|
||||
(line) =>
|
||||
!line.startsWith('VAPID_SUBJECT=') &&
|
||||
!line.startsWith('VAPID_PUBLIC_KEY=') &&
|
||||
!line.startsWith('VAPID_PRIVATE_KEY='),
|
||||
);
|
||||
|
||||
const nonEmptyLines = updatedLines.filter((line) => line.trim() !== '');
|
||||
|
||||
const newEnvContent =
|
||||
nonEmptyLines.join('\n') +
|
||||
(nonEmptyLines.length > 0 ? '\n' : '') +
|
||||
`${vapidSubject}\n${vapidPublicKey}\n${vapidPrivateKey}\n`;
|
||||
|
||||
fs.writeFileSync(envPath, newEnvContent);
|
||||
|
||||
console.log('VAPID keys generated and added to .env file:');
|
||||
console.log(` Public Key: ${vapidKeys.publicKey}`);
|
||||
console.log(` Private Key: ${vapidKeys.privateKey}`);
|
||||
console.log('\nKeep your private key secret!');
|
||||
}
|
||||
|
||||
generateVapidKeys();
|
||||
26
backend/scripts/reset-db.js
Normal file
26
backend/scripts/reset-db.js
Normal file
@ -0,0 +1,26 @@
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
async function resetDatabase() {
|
||||
console.log('Resetting database...');
|
||||
|
||||
try {
|
||||
// Drop and recreate the database
|
||||
const { stdout, stderr } = await execAsync(`
|
||||
PGPASSWORD=placebo_password psql -h localhost -U placebo_user -d postgres -c "DROP DATABASE IF EXISTS placebo_backend_db;"
|
||||
PGPASSWORD=placebo_password psql -h localhost -U placebo_user -d postgres -c "CREATE DATABASE placebo_backend_db;"
|
||||
`);
|
||||
|
||||
if (stderr && !stderr.includes('warning')) {
|
||||
console.error('Error:', stderr);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Database reset successfully!');
|
||||
} catch (error) {
|
||||
console.error('Failed to reset database:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
resetDatabase();
|
||||
12
backend/scripts/reset-db.sh
Executable file
12
backend/scripts/reset-db.sh
Executable file
@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "Resetting PostgreSQL database..."
|
||||
|
||||
# Connect to PostgreSQL and reset the database (without -t for TTY)
|
||||
docker exec -i placebo-postgres-dev psql -U placebo_user -d postgres <<EOF
|
||||
DROP DATABASE IF EXISTS placebo_backend_db;
|
||||
CREATE DATABASE placebo_backend_db;
|
||||
EOF
|
||||
|
||||
echo "Database reset complete!"
|
||||
echo "The new schema will be created when the backend starts with DATABASE_SYNCHRONIZE=true"
|
||||
41
backend/scripts/seed-admin.ts
Normal file
41
backend/scripts/seed-admin.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { UserService } from '../src/modules/users/user.service';
|
||||
import { UserRole } from '../src/modules/entities';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule);
|
||||
const userService = app.get(UserService);
|
||||
|
||||
try {
|
||||
// Check if admin user already exists
|
||||
const existingAdmin = await userService.findByUsername('admin');
|
||||
if (existingAdmin) {
|
||||
console.log('Admin user already exists');
|
||||
await app.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create admin user
|
||||
const adminUser = await userService.create({
|
||||
username: 'admin',
|
||||
email: 'admin@placebo.mk',
|
||||
password: 'admin123', // Change this in production!
|
||||
role: UserRole.ADMIN,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
console.log('Admin user created successfully:');
|
||||
console.log(`Username: ${adminUser.username}`);
|
||||
console.log(`Email: ${adminUser.email}`);
|
||||
console.log(`Role: ${adminUser.role}`);
|
||||
console.log('\n⚠️ IMPORTANT: Change the default password immediately!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating admin user:', error);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
65
backend/scripts/seed-categories.ts
Normal file
65
backend/scripts/seed-categories.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Category } from '../src/modules/entities';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule);
|
||||
|
||||
try {
|
||||
const categoryRepository = app.get(getRepositoryToken(Category));
|
||||
|
||||
// Define categories with Macedonian names and English slugs
|
||||
const categories = [
|
||||
{
|
||||
name: 'Спорт',
|
||||
slug: 'sport',
|
||||
description: 'Спортски вести и анализи',
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
name: 'Уметност',
|
||||
slug: 'art',
|
||||
description: 'Уметност, култура и забава',
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
name: 'Наука',
|
||||
slug: 'science',
|
||||
description: 'Научни откритија и технологија',
|
||||
order: 3,
|
||||
},
|
||||
];
|
||||
|
||||
console.log('Seeding categories...');
|
||||
|
||||
for (const categoryData of categories) {
|
||||
// Check if category already exists
|
||||
const existingCategory = await categoryRepository.findOne({
|
||||
where: { slug: categoryData.slug },
|
||||
});
|
||||
|
||||
if (existingCategory) {
|
||||
console.log(`Category "${categoryData.name}" (${categoryData.slug}) already exists`);
|
||||
} else {
|
||||
const category = categoryRepository.create(categoryData);
|
||||
await categoryRepository.save(category);
|
||||
console.log(`Created category: "${categoryData.name}" (${categoryData.slug})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n✅ Category seeding completed!');
|
||||
console.log('Categories available:');
|
||||
const allCategories = await categoryRepository.find({ order: { order: 'ASC' } });
|
||||
allCategories.forEach((cat: Category) => {
|
||||
console.log(` • ${cat.name} (${cat.slug}) - ${cat.description || 'No description'}`);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error seeding categories:', error);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@ -1,5 +1,6 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
import { Public } from './modules/auth/public.decorator';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
@ -9,4 +10,13 @@ export class AppController {
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('health')
|
||||
healthCheck(): { status: string; timestamp: string } {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,30 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { ArticlesModule } from './modules/articles.module';
|
||||
import { StrapiModule } from './modules/strapi.module';
|
||||
import { Article, Author, Category } from './modules/entities';
|
||||
import { LiveBlogModule } from './modules/live-blog.module';
|
||||
import { UserModule } from './modules/users/user.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { CommentModule } from './modules/comment/comment.module';
|
||||
import { AnalyticsModule } from './modules/analytics/analytics.module';
|
||||
import { PushModule } from './modules/push/push.module';
|
||||
import {
|
||||
Article,
|
||||
Author,
|
||||
Category,
|
||||
LiveBlog,
|
||||
LiveBlogUpdate,
|
||||
User,
|
||||
Comment,
|
||||
Reaction,
|
||||
} from './modules/entities';
|
||||
import { ShareEvent } from './modules/analytics/analytics.entity';
|
||||
import { PushSubscriptionEntity } from './modules/push/push-subscription.entity';
|
||||
import { JwtAuthPublicGuard } from './modules/auth/jwt-auth-public.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -13,16 +32,43 @@ import { Article, Author, Category } from './modules/entities';
|
||||
isGlobal: true,
|
||||
}),
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'sqlite',
|
||||
database: process.env.DATABASE_PATH ?? './database.sqlite',
|
||||
entities: [Article, Author, Category],
|
||||
synchronize: process.env.NODE_ENV !== 'production',
|
||||
logging: process.env.NODE_ENV === 'development',
|
||||
type: 'postgres',
|
||||
host: process.env.DATABASE_HOST || 'localhost',
|
||||
port: parseInt(process.env.DATABASE_PORT || '5432', 10),
|
||||
username: process.env.DATABASE_USERNAME || 'placebo_user',
|
||||
password: process.env.DATABASE_PASSWORD || 'placebo_password',
|
||||
database: process.env.DATABASE_NAME || 'placebo_backend_db',
|
||||
entities: [
|
||||
Article,
|
||||
Author,
|
||||
Category,
|
||||
LiveBlog,
|
||||
LiveBlogUpdate,
|
||||
User,
|
||||
Comment,
|
||||
Reaction,
|
||||
ShareEvent,
|
||||
PushSubscriptionEntity,
|
||||
],
|
||||
synchronize: process.env.DATABASE_SYNCHRONIZE === 'true',
|
||||
logging: process.env.DATABASE_LOGGING === 'true',
|
||||
}),
|
||||
ArticlesModule,
|
||||
StrapiModule,
|
||||
LiveBlogModule,
|
||||
UserModule,
|
||||
AuthModule,
|
||||
CommentModule,
|
||||
AnalyticsModule,
|
||||
PushModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
providers: [
|
||||
AppService,
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: JwtAuthPublicGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@ -1,20 +1,45 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Build allowed origins list from environment variables
|
||||
const allowedOrigins = [
|
||||
process.env.FRONTEND_URL ?? 'http://localhost:5173',
|
||||
'https://placebo.mk', // Production domain
|
||||
'https://www.placebo.mk', // Also allow www subdomain
|
||||
process.env.PWA_URL ?? 'http://localhost:5174',
|
||||
process.env.STRAPI_URL ?? 'http://localhost:1337',
|
||||
];
|
||||
].filter(Boolean); // Remove any undefined/null values
|
||||
|
||||
console.log('CORS enabled for origins:', allowedOrigins);
|
||||
|
||||
app.enableCors({
|
||||
origin: allowedOrigins,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'Accept', 'Origin'],
|
||||
exposedHeaders: ['Content-Range', 'X-Content-Range'],
|
||||
credentials: true,
|
||||
maxAge: 3600,
|
||||
});
|
||||
|
||||
app.setGlobalPrefix('api/v1');
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
|
||||
// Apply global validation pipe
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const port = process.env.PORT ?? 3000;
|
||||
const host = '0.0.0.0'; // Bind to all interfaces for Docker
|
||||
await app.listen(port, host);
|
||||
|
||||
console.log(`Application is running on: http://${host}:${port}`);
|
||||
}
|
||||
void bootstrap();
|
||||
|
||||
76
backend/src/modules/analytics/analytics.controller.ts
Normal file
76
backend/src/modules/analytics/analytics.controller.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
Get,
|
||||
Query,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
import {
|
||||
TrackShareDto,
|
||||
GetShareStatsDto,
|
||||
ShareStatsResponse,
|
||||
} from './analytics.dto';
|
||||
import { Public } from '../auth/public.decorator';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { UserRole } from '../entities';
|
||||
|
||||
@Controller('analytics')
|
||||
export class AnalyticsController {
|
||||
constructor(private readonly analyticsService: AnalyticsService) {}
|
||||
|
||||
@Public()
|
||||
@Post('share')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async trackShare(@Body() trackShareDto: TrackShareDto) {
|
||||
return await this.analyticsService.trackShare(trackShareDto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@Get('shares')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async getShareStats(
|
||||
@Query() getShareStatsDto: GetShareStatsDto,
|
||||
): Promise<ShareStatsResponse[]> {
|
||||
return await this.analyticsService.getShareStats(getShareStatsDto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@Get('shares/top')
|
||||
async getTopSharedArticles(@Query('limit') limit: string) {
|
||||
const limitNum = limit ? parseInt(limit) : 10;
|
||||
return await this.analyticsService.getTopSharedArticles(limitNum);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@Get('shares/trends')
|
||||
async getShareTrends(
|
||||
@Query('startDate') startDate: string,
|
||||
@Query('endDate') endDate: string,
|
||||
@Query('interval') interval: 'day' | 'week' | 'month',
|
||||
) {
|
||||
return await this.analyticsService.getShareTrends(
|
||||
new Date(startDate),
|
||||
new Date(endDate),
|
||||
interval || 'day',
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@Get('shares/total')
|
||||
async getTotalShareStats() {
|
||||
return await this.analyticsService.getTotalShareStats();
|
||||
}
|
||||
}
|
||||
48
backend/src/modules/analytics/analytics.dto.ts
Normal file
48
backend/src/modules/analytics/analytics.dto.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import type { SharePlatform } from './analytics.entity';
|
||||
|
||||
export class TrackShareDto {
|
||||
@IsUUID()
|
||||
articleId: string;
|
||||
|
||||
@IsEnum(['facebook', 'twitter', 'instagram', 'tiktok', 'telegram', 'link'])
|
||||
platform: SharePlatform;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userAgent?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
export class GetShareStatsDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
articleId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
startDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export class ShareStatsResponse {
|
||||
articleId: string;
|
||||
articleTitle: string;
|
||||
facebookShares: number;
|
||||
twitterShares: number;
|
||||
instagramShares: number;
|
||||
tiktokShares: number;
|
||||
telegramShares: number;
|
||||
linkShares: number;
|
||||
totalShares: number;
|
||||
views: number;
|
||||
shareRate: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
59
backend/src/modules/analytics/analytics.entity.ts
Normal file
59
backend/src/modules/analytics/analytics.entity.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Article } from '../entities';
|
||||
|
||||
export type SharePlatform =
|
||||
| 'facebook'
|
||||
| 'twitter'
|
||||
| 'instagram'
|
||||
| 'tiktok'
|
||||
| 'telegram'
|
||||
| 'link';
|
||||
|
||||
@Entity('share_events')
|
||||
export class ShareEvent {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
articleId: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
platform: SharePlatform;
|
||||
|
||||
@Column({ nullable: true })
|
||||
userId: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
userAgent: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
ipAddress: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@ManyToOne(() => Article, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'articleId' })
|
||||
article: Article;
|
||||
}
|
||||
|
||||
export interface ShareStats {
|
||||
articleId: string;
|
||||
articleTitle: string;
|
||||
facebookShares: number;
|
||||
twitterShares: number;
|
||||
instagramShares: number;
|
||||
tiktokShares: number;
|
||||
telegramShares: number;
|
||||
linkShares: number;
|
||||
totalShares: number;
|
||||
views: number;
|
||||
shareRate: number;
|
||||
}
|
||||
14
backend/src/modules/analytics/analytics.module.ts
Normal file
14
backend/src/modules/analytics/analytics.module.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AnalyticsController } from './analytics.controller';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
import { ShareEvent } from './analytics.entity';
|
||||
import { Article } from '../entities';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ShareEvent, Article])],
|
||||
controllers: [AnalyticsController],
|
||||
providers: [AnalyticsService],
|
||||
exports: [AnalyticsService],
|
||||
})
|
||||
export class AnalyticsModule {}
|
||||
282
backend/src/modules/analytics/analytics.service.ts
Normal file
282
backend/src/modules/analytics/analytics.service.ts
Normal file
@ -0,0 +1,282 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ShareEvent, SharePlatform } from './analytics.entity';
|
||||
import { Article } from '../entities';
|
||||
import {
|
||||
TrackShareDto,
|
||||
GetShareStatsDto,
|
||||
ShareStatsResponse,
|
||||
} from './analytics.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AnalyticsService {
|
||||
private readonly logger = new Logger(AnalyticsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ShareEvent)
|
||||
private readonly shareEventRepository: Repository<ShareEvent>,
|
||||
@InjectRepository(Article)
|
||||
private readonly articleRepository: Repository<Article>,
|
||||
) {}
|
||||
|
||||
async trackShare(trackShareDto: TrackShareDto): Promise<ShareEvent> {
|
||||
const shareEvent = this.shareEventRepository.create(trackShareDto);
|
||||
|
||||
// Also update the article's share counters
|
||||
await this.incrementArticleShareCounter(
|
||||
trackShareDto.articleId,
|
||||
trackShareDto.platform,
|
||||
);
|
||||
|
||||
return await this.shareEventRepository.save(shareEvent);
|
||||
}
|
||||
|
||||
private async incrementArticleShareCounter(
|
||||
articleId: string,
|
||||
platform: SharePlatform,
|
||||
): Promise<void> {
|
||||
const updateField = this.getShareCounterField(platform);
|
||||
if (!updateField) return;
|
||||
|
||||
await this.articleRepository
|
||||
.createQueryBuilder()
|
||||
.update(Article)
|
||||
.set({ [updateField]: () => `${updateField} + 1` })
|
||||
.where('id = :id', { id: articleId })
|
||||
.execute();
|
||||
}
|
||||
|
||||
private getShareCounterField(platform: SharePlatform): string | null {
|
||||
switch (platform) {
|
||||
case 'facebook':
|
||||
return 'facebookShares';
|
||||
case 'twitter':
|
||||
return 'twitterShares';
|
||||
case 'instagram':
|
||||
return 'instagramShares';
|
||||
case 'tiktok':
|
||||
return 'tiktokShares';
|
||||
case 'telegram':
|
||||
return 'telegramShares';
|
||||
default:
|
||||
return null; // 'link' shares don't increment counters
|
||||
}
|
||||
}
|
||||
|
||||
async getShareStats(
|
||||
getShareStatsDto: GetShareStatsDto,
|
||||
): Promise<ShareStatsResponse[]> {
|
||||
const query = this.articleRepository
|
||||
.createQueryBuilder('article')
|
||||
.select([
|
||||
'article.id as "articleId"',
|
||||
'article.title as "articleTitle"',
|
||||
'article.facebookShares as "facebookShares"',
|
||||
'article.twitterShares as "twitterShares"',
|
||||
'article.instagramShares as "instagramShares"',
|
||||
'article.tiktokShares as "tiktokShares"',
|
||||
'article.telegramShares as "telegramShares"',
|
||||
'article.views as "views"',
|
||||
'article.createdAt as "createdAt"',
|
||||
'article.updatedAt as "updatedAt"',
|
||||
])
|
||||
.addSelect(
|
||||
`(article.facebookShares + article.twitterShares + article.instagramShares + article.tiktokShares + article.telegramShares) as "totalShares"`,
|
||||
)
|
||||
.addSelect(
|
||||
`CASE
|
||||
WHEN article.views > 0
|
||||
THEN ROUND(
|
||||
(article.facebookShares + article.twitterShares + article.instagramShares + article.tiktokShares + article.telegramShares)::decimal / article.views * 100,
|
||||
2
|
||||
)
|
||||
ELSE 0
|
||||
END as "shareRate"`,
|
||||
);
|
||||
|
||||
if (getShareStatsDto.articleId) {
|
||||
query.where('article.id = :articleId', {
|
||||
articleId: getShareStatsDto.articleId,
|
||||
});
|
||||
}
|
||||
|
||||
if (getShareStatsDto.startDate) {
|
||||
query.andWhere('article.createdAt >= :startDate', {
|
||||
startDate: getShareStatsDto.startDate,
|
||||
});
|
||||
}
|
||||
|
||||
if (getShareStatsDto.endDate) {
|
||||
query.andWhere('article.createdAt <= :endDate', {
|
||||
endDate: getShareStatsDto.endDate,
|
||||
});
|
||||
}
|
||||
|
||||
query.orderBy('"totalShares"', 'DESC');
|
||||
|
||||
const rawResults = await query.getRawMany<{
|
||||
articleId: string;
|
||||
articleTitle: string;
|
||||
facebookShares: string;
|
||||
twitterShares: string;
|
||||
instagramShares: string;
|
||||
tiktokShares: string;
|
||||
telegramShares: string;
|
||||
views: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
totalShares: string;
|
||||
shareRate: string;
|
||||
}>();
|
||||
|
||||
// Get link shares from share_events table
|
||||
const results: ShareStatsResponse[] = [];
|
||||
for (const rawResult of rawResults) {
|
||||
const linkShares = await this.shareEventRepository.count({
|
||||
where: {
|
||||
articleId: rawResult.articleId,
|
||||
platform: 'link',
|
||||
},
|
||||
});
|
||||
|
||||
const facebookShares = parseInt(rawResult.facebookShares) || 0;
|
||||
const twitterShares = parseInt(rawResult.twitterShares) || 0;
|
||||
const instagramShares = parseInt(rawResult.instagramShares) || 0;
|
||||
const tiktokShares = parseInt(rawResult.tiktokShares) || 0;
|
||||
const telegramShares = parseInt(rawResult.telegramShares) || 0;
|
||||
const views = parseInt(rawResult.views) || 0;
|
||||
const baseTotalShares =
|
||||
facebookShares +
|
||||
twitterShares +
|
||||
instagramShares +
|
||||
tiktokShares +
|
||||
telegramShares;
|
||||
const totalShares = baseTotalShares + linkShares;
|
||||
const shareRate =
|
||||
views > 0 ? parseFloat(((totalShares / views) * 100).toFixed(2)) : 0;
|
||||
|
||||
results.push({
|
||||
articleId: rawResult.articleId,
|
||||
articleTitle: rawResult.articleTitle,
|
||||
facebookShares,
|
||||
twitterShares,
|
||||
instagramShares,
|
||||
tiktokShares,
|
||||
telegramShares,
|
||||
linkShares,
|
||||
totalShares,
|
||||
views,
|
||||
shareRate,
|
||||
createdAt: new Date(rawResult.createdAt),
|
||||
updatedAt: new Date(rawResult.updatedAt),
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async getTopSharedArticles(
|
||||
limit: number = 10,
|
||||
): Promise<ShareStatsResponse[]> {
|
||||
const stats = await this.getShareStats({});
|
||||
return stats.slice(0, limit);
|
||||
}
|
||||
|
||||
async getShareTrends(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
interval: 'day' | 'week' | 'month' = 'day',
|
||||
): Promise<
|
||||
Array<{ period: string; platform: SharePlatform; count: number }>
|
||||
> {
|
||||
const dateFormat =
|
||||
interval === 'day'
|
||||
? 'YYYY-MM-DD'
|
||||
: interval === 'week'
|
||||
? 'YYYY-WW'
|
||||
: 'YYYY-MM';
|
||||
|
||||
const query = this.shareEventRepository
|
||||
.createQueryBuilder('share_event')
|
||||
.select([
|
||||
`TO_CHAR(share_event.createdAt, '${dateFormat}') as period`,
|
||||
'share_event.platform as platform',
|
||||
'COUNT(*) as count',
|
||||
])
|
||||
.where('share_event.createdAt >= :startDate', { startDate })
|
||||
.andWhere('share_event.createdAt <= :endDate', { endDate })
|
||||
.groupBy('period, platform')
|
||||
.orderBy('period', 'ASC');
|
||||
|
||||
const rawResults = await query.getRawMany<{
|
||||
period: string;
|
||||
platform: string;
|
||||
count: string;
|
||||
}>();
|
||||
|
||||
// Convert platform strings to SharePlatform type
|
||||
return rawResults.map((result) => ({
|
||||
period: result.period,
|
||||
platform: result.platform as SharePlatform,
|
||||
count: parseInt(result.count) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async getTotalShareStats(): Promise<{
|
||||
totalShares: number;
|
||||
facebookShares: number;
|
||||
twitterShares: number;
|
||||
instagramShares: number;
|
||||
tiktokShares: number;
|
||||
telegramShares: number;
|
||||
linkShares: number;
|
||||
}> {
|
||||
interface ArticleStatsRaw {
|
||||
facebookShares: string;
|
||||
twitterShares: string;
|
||||
instagramShares: string;
|
||||
tiktokShares: string;
|
||||
telegramShares: string;
|
||||
}
|
||||
|
||||
const articleStats = (await this.articleRepository
|
||||
.createQueryBuilder('article')
|
||||
.select([
|
||||
'SUM(article.facebookShares) as facebookShares',
|
||||
'SUM(article.twitterShares) as twitterShares',
|
||||
'SUM(article.instagramShares) as instagramShares',
|
||||
'SUM(article.tiktokShares) as tiktokShares',
|
||||
'SUM(article.telegramShares) as telegramShares',
|
||||
])
|
||||
.getRawOne()) as ArticleStatsRaw;
|
||||
|
||||
const linkShares = await this.shareEventRepository.count({
|
||||
where: { platform: 'link' },
|
||||
});
|
||||
|
||||
const facebookShares = parseInt(articleStats?.facebookShares || '0') || 0;
|
||||
const twitterShares = parseInt(articleStats?.twitterShares || '0') || 0;
|
||||
const instagramShares = parseInt(articleStats?.instagramShares || '0') || 0;
|
||||
const tiktokShares = parseInt(articleStats?.tiktokShares || '0') || 0;
|
||||
const telegramShares = parseInt(articleStats?.telegramShares || '0') || 0;
|
||||
|
||||
const totalShares =
|
||||
facebookShares +
|
||||
twitterShares +
|
||||
instagramShares +
|
||||
tiktokShares +
|
||||
telegramShares +
|
||||
linkShares;
|
||||
|
||||
return {
|
||||
totalShares,
|
||||
facebookShares,
|
||||
twitterShares,
|
||||
instagramShares,
|
||||
tiktokShares,
|
||||
telegramShares,
|
||||
linkShares,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -4,10 +4,11 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Patch,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
ValidationPipe,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ArticlesService } from './articles.service';
|
||||
import {
|
||||
@ -16,42 +17,72 @@ import {
|
||||
FindArticlesDto,
|
||||
} from './articles.dto';
|
||||
|
||||
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||
import { RolesGuard } from './auth/roles.guard';
|
||||
import { Roles } from './auth/roles.decorator';
|
||||
import { Public } from './auth/public.decorator';
|
||||
import { UserRole } from './entities';
|
||||
|
||||
@Controller('articles')
|
||||
export class ArticlesController {
|
||||
constructor(private readonly articlesService: ArticlesService) {}
|
||||
|
||||
@Post()
|
||||
create(@Body(new ValidationPipe({ transform: true })) dto: CreateArticleDto) {
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
create(@Body() dto: CreateArticleDto) {
|
||||
return this.articlesService.create(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(
|
||||
@Query(new ValidationPipe({ transform: true })) dto: FindArticlesDto,
|
||||
) {
|
||||
@Public()
|
||||
findAll(@Query() dto: FindArticlesDto) {
|
||||
return this.articlesService.findAll(dto);
|
||||
}
|
||||
|
||||
@Get('hero')
|
||||
@Public()
|
||||
findHero() {
|
||||
return this.articlesService.findHeroArticle();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Public()
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.articlesService.findOne(id);
|
||||
}
|
||||
|
||||
@Get('slug/:slug')
|
||||
@Public()
|
||||
findBySlug(@Param('slug') slug: string) {
|
||||
return this.articlesService.findBySlug(slug);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body(new ValidationPipe({ transform: true })) dto: UpdateArticleDto,
|
||||
) {
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
update(@Param('id') id: string, @Body() dto: UpdateArticleDto) {
|
||||
return this.articlesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
remove(@Param('id') id: string) {
|
||||
return this.articlesService.remove(id);
|
||||
}
|
||||
|
||||
@Patch(':id/archive')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
archive(@Param('id') id: string) {
|
||||
return this.articlesService.archive(id);
|
||||
}
|
||||
|
||||
@Patch(':id/publish')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
publish(@Param('id') id: string) {
|
||||
return this.articlesService.publish(id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,8 +5,16 @@ import {
|
||||
IsArray,
|
||||
IsUUID,
|
||||
IsNumber,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
} from 'class-validator';
|
||||
import { ArticleStatus } from './entities';
|
||||
import {
|
||||
ArticleStatus,
|
||||
LiveBlogStatus,
|
||||
ImagePosition,
|
||||
ImageSize,
|
||||
VideoPosition,
|
||||
} from './entities';
|
||||
|
||||
export class CreateArticleDto {
|
||||
@IsString()
|
||||
@ -40,6 +48,10 @@ export class CreateArticleDto {
|
||||
@IsString()
|
||||
strapiId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isHero?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
authorId?: string;
|
||||
@ -47,6 +59,50 @@ export class CreateArticleDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ImagePosition)
|
||||
imagePosition?: ImagePosition;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ImageSize)
|
||||
imageSize?: ImageSize;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(VideoPosition)
|
||||
videoPosition?: VideoPosition;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoCaption?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ogTitle?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ogDescription?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ogImage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
twitterTitle?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
twitterDescription?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
twitterImage?: string;
|
||||
}
|
||||
|
||||
export class UpdateArticleDto {
|
||||
@ -83,6 +139,10 @@ export class UpdateArticleDto {
|
||||
@IsString()
|
||||
strapiId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isHero?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
authorId?: string;
|
||||
@ -90,6 +150,50 @@ export class UpdateArticleDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ImagePosition)
|
||||
imagePosition?: ImagePosition;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ImageSize)
|
||||
imageSize?: ImageSize;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(VideoPosition)
|
||||
videoPosition?: VideoPosition;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoCaption?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ogTitle?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ogDescription?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ogImage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
twitterTitle?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
twitterDescription?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
twitterImage?: string;
|
||||
}
|
||||
|
||||
export class FindArticlesDto {
|
||||
@ -106,8 +210,8 @@ export class FindArticlesDto {
|
||||
tag?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ArticleStatus)
|
||||
status?: ArticleStatus;
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ -120,3 +224,201 @@ export class FindArticlesDto {
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class FindLiveBlogsDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
author?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPinned?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class CreateLiveBlogDto {
|
||||
@IsString()
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
slug: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(LiveBlogStatus)
|
||||
status?: LiveBlogStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPinned?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
strapiId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
authorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
featuredImage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ImagePosition)
|
||||
imagePosition?: ImagePosition;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ImageSize)
|
||||
imageSize?: ImageSize;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(VideoPosition)
|
||||
videoPosition?: VideoPosition;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoCaption?: string;
|
||||
}
|
||||
|
||||
export class UpdateLiveBlogDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(LiveBlogStatus)
|
||||
status?: LiveBlogStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPinned?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
strapiId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
authorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
featuredImage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ImagePosition)
|
||||
imagePosition?: ImagePosition;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ImageSize)
|
||||
imageSize?: ImageSize;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(VideoPosition)
|
||||
videoPosition?: VideoPosition;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoCaption?: string;
|
||||
}
|
||||
|
||||
export class CreateLiveBlogUpdateDto {
|
||||
@IsString()
|
||||
content: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPinned?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
authorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
scheduledAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
image?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
authorName?: string;
|
||||
}
|
||||
|
||||
export class UpdateLiveBlogUpdateDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
content?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPinned?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
authorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
image?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
authorName?: string;
|
||||
}
|
||||
|
||||
356
backend/src/modules/articles.dto.ts.backup
Normal file
356
backend/src/modules/articles.dto.ts.backup
Normal file
@ -0,0 +1,356 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsEnum,
|
||||
IsArray,
|
||||
IsUUID,
|
||||
IsNumber,
|
||||
IsBoolean,
|
||||
IsDate,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
ArticleStatus,
|
||||
LiveBlogStatus,
|
||||
ImagePosition,
|
||||
ImageSize,
|
||||
VideoPosition,
|
||||
} from './entities';
|
||||
|
||||
export class CreateArticleDto {
|
||||
@IsString()
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
content: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
excerpt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
featuredImage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ArticleStatus)
|
||||
status?: ArticleStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
strapiId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
authorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ImagePosition)
|
||||
imagePosition?: ImagePosition;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ImageSize)
|
||||
imageSize?: ImageSize;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(VideoPosition)
|
||||
videoPosition?: VideoPosition;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoCaption?: string;
|
||||
}
|
||||
|
||||
export class UpdateArticleDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
content?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
excerpt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
featuredImage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ArticleStatus)
|
||||
status?: ArticleStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
strapiId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
authorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ImagePosition)
|
||||
imagePosition?: ImagePosition;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ImageSize)
|
||||
imageSize?: ImageSize;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(VideoPosition)
|
||||
videoPosition?: VideoPosition;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoCaption?: string;
|
||||
}
|
||||
|
||||
export class FindArticlesDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
author?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tag?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class CreateLiveBlogDto {
|
||||
@IsString()
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
slug: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(LiveBlogStatus)
|
||||
status?: LiveBlogStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPinned?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
strapiId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
authorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
featuredImage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ImagePosition)
|
||||
imagePosition?: ImagePosition;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ImageSize)
|
||||
imageSize?: ImageSize;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(VideoPosition)
|
||||
videoPosition?: VideoPosition;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoCaption?: string;
|
||||
}
|
||||
|
||||
export class UpdateLiveBlogDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(LiveBlogStatus)
|
||||
status?: LiveBlogStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPinned?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
strapiId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
authorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
featuredImage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ImagePosition)
|
||||
imagePosition?: ImagePosition;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ImageSize)
|
||||
imageSize?: ImageSize;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(VideoPosition)
|
||||
videoPosition?: VideoPosition;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoCaption?: string;
|
||||
}
|
||||
|
||||
export class CreateLiveBlogUpdateDto {
|
||||
@IsString()
|
||||
content: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPinned?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
authorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDate()
|
||||
scheduledAt?: Date;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
strapiId?: string;
|
||||
}
|
||||
|
||||
export class UpdateLiveBlogUpdateDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
content?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPinned?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
authorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDate()
|
||||
scheduledAt?: Date;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
strapiId?: string;
|
||||
}
|
||||
|
||||
export class FindLiveBlogsDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
author?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPinned?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
}
|
||||
@ -1,11 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ArticlesService } from './articles.service';
|
||||
import { ArticlesController } from './articles.controller';
|
||||
import { Article, Author, Category } from './entities';
|
||||
import { StrapiModule } from './strapi.module';
|
||||
import { PushModule } from './push/push.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Article, Author, Category])],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Article, Author, Category]),
|
||||
forwardRef(() => StrapiModule),
|
||||
forwardRef(() => PushModule),
|
||||
],
|
||||
controllers: [ArticlesController],
|
||||
providers: [ArticlesService],
|
||||
exports: [ArticlesService],
|
||||
|
||||
@ -1,18 +1,34 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
Logger,
|
||||
Inject,
|
||||
forwardRef,
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Article, ArticleStatus } from './entities';
|
||||
import { StrapiService } from './strapi.service';
|
||||
import {
|
||||
CreateArticleDto,
|
||||
UpdateArticleDto,
|
||||
FindArticlesDto,
|
||||
} from './articles.dto';
|
||||
import { PushService } from './push/push.service';
|
||||
|
||||
@Injectable()
|
||||
export class ArticlesService {
|
||||
private readonly logger = new Logger(ArticlesService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Article)
|
||||
private readonly articleRepository: Repository<Article>,
|
||||
@Inject(forwardRef(() => StrapiService))
|
||||
private readonly strapiService: StrapiService,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => PushService))
|
||||
private readonly pushService?: PushService,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateArticleDto): Promise<Article> {
|
||||
@ -26,21 +42,23 @@ export class ArticlesService {
|
||||
async findAll(
|
||||
dto: FindArticlesDto,
|
||||
): Promise<{ data: Article[]; total: number }> {
|
||||
const {
|
||||
category,
|
||||
author,
|
||||
tag,
|
||||
status = ArticleStatus.PUBLISHED,
|
||||
search,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
} = dto;
|
||||
const { category, author, tag, status, search, page = 1, limit = 10 } = dto;
|
||||
|
||||
const queryBuilder = this.articleRepository
|
||||
.createQueryBuilder('article')
|
||||
.leftJoinAndSelect('article.author', 'author')
|
||||
.leftJoinAndSelect('article.category', 'category')
|
||||
.where('article.status = :status', { status });
|
||||
.leftJoinAndSelect('article.category', 'category');
|
||||
|
||||
// Handle status filter - can be single value or comma-separated list
|
||||
if (status) {
|
||||
if (typeof status === 'string' && status.includes(',')) {
|
||||
const statuses = status.split(',').map((s) => s.trim());
|
||||
queryBuilder.where('article.status IN (:...statuses)', { statuses });
|
||||
} else {
|
||||
queryBuilder.where('article.status = :status', { status });
|
||||
}
|
||||
}
|
||||
// If no status specified, return all articles (for admin dashboard)
|
||||
|
||||
if (category) {
|
||||
queryBuilder.andWhere('category.slug = :category', { category });
|
||||
@ -80,6 +98,9 @@ export class ArticlesService {
|
||||
throw new NotFoundException(`Article with ID ${id} not found`);
|
||||
}
|
||||
|
||||
// Increment view count
|
||||
await this.articleRepository.increment({ id }, 'views', 1);
|
||||
|
||||
return article;
|
||||
}
|
||||
|
||||
@ -93,36 +114,234 @@ export class ArticlesService {
|
||||
throw new NotFoundException(`Article with slug ${slug} not found`);
|
||||
}
|
||||
|
||||
// Increment view count
|
||||
await this.articleRepository.increment({ slug }, 'views', 1);
|
||||
|
||||
return article;
|
||||
}
|
||||
|
||||
async findOneWithoutIncrement(id: string): Promise<Article> {
|
||||
const article = await this.articleRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['author', 'category'],
|
||||
});
|
||||
|
||||
if (!article) {
|
||||
throw new NotFoundException(`Article with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return article;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateArticleDto): Promise<Article> {
|
||||
const article = await this.findOne(id);
|
||||
const article = await this.findOneWithoutIncrement(id);
|
||||
const oldStatus = article.status;
|
||||
Object.assign(article, dto);
|
||||
return await this.articleRepository.save(article);
|
||||
const savedArticle = await this.articleRepository.save(article);
|
||||
|
||||
// Update Strapi if status changed and article has strapiId
|
||||
if (
|
||||
dto.status !== undefined &&
|
||||
dto.status !== oldStatus &&
|
||||
article.strapiId
|
||||
) {
|
||||
try {
|
||||
await this.strapiService.updateArticleStatusInStrapi(
|
||||
article.strapiId,
|
||||
dto.status,
|
||||
);
|
||||
} catch (error) {
|
||||
// Log error but don't fail - backend status is updated
|
||||
this.logger.error(
|
||||
`Failed to update Strapi status for article ${id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return savedArticle;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const article = await this.findOne(id);
|
||||
const article = await this.findOneWithoutIncrement(id);
|
||||
|
||||
// Delete from Strapi if article has strapiId
|
||||
if (article.strapiId) {
|
||||
try {
|
||||
await this.strapiService.deleteArticleFromStrapi(article.strapiId);
|
||||
} catch (error) {
|
||||
// Log error but don't fail - we still want to delete from backend
|
||||
this.logger.error(`Failed to delete article ${id} from Strapi:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
await this.articleRepository.remove(article);
|
||||
}
|
||||
|
||||
async archive(id: string): Promise<Article> {
|
||||
const article = await this.findOneWithoutIncrement(id);
|
||||
article.status = ArticleStatus.ARCHIVED;
|
||||
const savedArticle = await this.articleRepository.save(article);
|
||||
|
||||
// Update Strapi if article has strapiId
|
||||
if (article.strapiId) {
|
||||
try {
|
||||
await this.strapiService.updateArticleStatusInStrapi(
|
||||
article.strapiId,
|
||||
ArticleStatus.ARCHIVED,
|
||||
);
|
||||
} catch (error) {
|
||||
// Log error but don't fail - backend status is updated
|
||||
this.logger.error(
|
||||
`Failed to update Strapi status for article ${id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return savedArticle;
|
||||
}
|
||||
|
||||
async publish(
|
||||
id: string,
|
||||
status: ArticleStatus = ArticleStatus.PUBLISHED,
|
||||
): Promise<Article> {
|
||||
const article = await this.findOneWithoutIncrement(id);
|
||||
const wasDraft = article.status === ArticleStatus.DRAFT;
|
||||
article.status = status;
|
||||
const savedArticle = await this.articleRepository.save(article);
|
||||
|
||||
// Update Strapi if article has strapiId
|
||||
if (article.strapiId) {
|
||||
try {
|
||||
await this.strapiService.updateArticleStatusInStrapi(
|
||||
article.strapiId,
|
||||
status,
|
||||
);
|
||||
} catch (error) {
|
||||
// Log error but don't fail - backend status is updated
|
||||
this.logger.error(
|
||||
`Failed to update Strapi status for article ${id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Send push notification for newly published articles
|
||||
if (wasDraft && status === ArticleStatus.PUBLISHED && this.pushService) {
|
||||
try {
|
||||
await this.pushService.notifyNewArticle(
|
||||
savedArticle.title,
|
||||
savedArticle.slug,
|
||||
);
|
||||
this.logger.log(
|
||||
`Push notification sent for article: ${savedArticle.title}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to send push notification:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return savedArticle;
|
||||
}
|
||||
|
||||
async syncFromStrapi(
|
||||
strapiId: string,
|
||||
data: Partial<CreateArticleDto>,
|
||||
): Promise<Article> {
|
||||
let article = await this.articleRepository.findOne({ where: { strapiId } });
|
||||
// Use upsert to handle race conditions and ensure uniqueness
|
||||
try {
|
||||
// First try to find existing article by strapiId
|
||||
const article = await this.articleRepository.findOne({
|
||||
where: { strapiId },
|
||||
});
|
||||
|
||||
if (article) {
|
||||
// Update existing article
|
||||
const currentStatus = article.status;
|
||||
Object.assign(article, data);
|
||||
|
||||
// Preserve archived status if article is already archived in our system
|
||||
// unless Strapi explicitly sends a different status
|
||||
if (
|
||||
currentStatus === ArticleStatus.ARCHIVED &&
|
||||
data.status !== ArticleStatus.ARCHIVED
|
||||
) {
|
||||
article.status = ArticleStatus.ARCHIVED;
|
||||
}
|
||||
|
||||
return await this.articleRepository.save(article);
|
||||
} else {
|
||||
// Create new article
|
||||
const newArticle = this.articleRepository.create({
|
||||
strapiId,
|
||||
...data,
|
||||
status: data.status || ArticleStatus.DRAFT,
|
||||
});
|
||||
|
||||
return await this.articleRepository.save(newArticle);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// If we get a unique constraint violation, try to find and update again
|
||||
// This handles race conditions where two syncs happen simultaneously
|
||||
const dbError = error as { code?: string; constraint?: string };
|
||||
if (
|
||||
dbError.code === '23505' &&
|
||||
dbError.constraint?.includes('strapiId')
|
||||
) {
|
||||
this.logger.warn(
|
||||
`Race condition detected for strapiId ${strapiId}, retrying...`,
|
||||
);
|
||||
|
||||
// Wait a bit and retry
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const existingArticle = await this.articleRepository.findOne({
|
||||
where: { strapiId },
|
||||
});
|
||||
|
||||
if (existingArticle) {
|
||||
const currentStatus = existingArticle.status;
|
||||
Object.assign(existingArticle, data);
|
||||
|
||||
if (
|
||||
currentStatus === ArticleStatus.ARCHIVED &&
|
||||
data.status !== ArticleStatus.ARCHIVED
|
||||
) {
|
||||
existingArticle.status = ArticleStatus.ARCHIVED;
|
||||
}
|
||||
|
||||
return await this.articleRepository.save(existingArticle);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-throw other errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async removeByStrapiId(strapiId: string): Promise<void> {
|
||||
const article = await this.articleRepository.findOne({
|
||||
where: { strapiId },
|
||||
});
|
||||
|
||||
if (!article) {
|
||||
article = this.articleRepository.create({
|
||||
strapiId,
|
||||
...data,
|
||||
status: data.status || ArticleStatus.DRAFT,
|
||||
});
|
||||
} else {
|
||||
Object.assign(article, data);
|
||||
this.logger.warn(`Article with strapiId ${strapiId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
return await this.articleRepository.save(article);
|
||||
await this.articleRepository.remove(article);
|
||||
this.logger.log(`Successfully deleted article with strapiId: ${strapiId}`);
|
||||
}
|
||||
|
||||
async findHeroArticle(): Promise<Article | null> {
|
||||
return this.articleRepository.findOne({
|
||||
where: {
|
||||
isHero: true,
|
||||
status: ArticleStatus.PUBLISHED,
|
||||
},
|
||||
relations: ['author', 'category'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
31
backend/src/modules/auth/auth.controller.ts
Normal file
31
backend/src/modules/auth/auth.controller.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginUserDto, CreateUserDto } from '../users/user.dto';
|
||||
import { LocalAuthGuard } from './local-auth.guard';
|
||||
import { Public } from './public.decorator';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@UseGuards(LocalAuthGuard)
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async login(@Body() loginUserDto: LoginUserDto) {
|
||||
return this.authService.login(loginUserDto);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('register')
|
||||
async register(@Body() createUserDto: CreateUserDto) {
|
||||
return this.authService.register(createUserDto);
|
||||
}
|
||||
}
|
||||
34
backend/src/modules/auth/auth.module.ts
Normal file
34
backend/src/modules/auth/auth.module.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { UserModule } from '../users/user.module';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { LocalStrategy } from './local.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UserModule,
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => {
|
||||
const expiration = configService.get<string>('JWT_EXPIRATION') || '7d';
|
||||
return {
|
||||
secret:
|
||||
configService.get<string>('JWT_SECRET') || 'default-secret-key',
|
||||
signOptions: {
|
||||
expiresIn: expiration ? parseInt(expiration) : 3600, // Convert to number
|
||||
},
|
||||
};
|
||||
},
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, LocalStrategy, JwtStrategy],
|
||||
exports: [AuthService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
80
backend/src/modules/auth/auth.service.ts
Normal file
80
backend/src/modules/auth/auth.service.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { UserService } from '../users/user.service';
|
||||
import { CreateUserDto, LoginUserDto } from '../users/user.dto';
|
||||
import { AuthResponse, JwtPayload } from './types';
|
||||
import { UserRole } from '../entities';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private userService: UserService,
|
||||
private jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async validateUser(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<{
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
} | null> {
|
||||
return this.userService.validateUser(username, password);
|
||||
}
|
||||
|
||||
async login(loginUserDto: LoginUserDto): Promise<AuthResponse> {
|
||||
const user = await this.validateUser(
|
||||
loginUserDto.username,
|
||||
loginUserDto.password,
|
||||
);
|
||||
if (!user) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role as UserRole,
|
||||
};
|
||||
|
||||
return {
|
||||
access_token: this.jwtService.sign(payload),
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role as UserRole,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async register(createUserDto: CreateUserDto): Promise<AuthResponse> {
|
||||
// Default new users to USER role
|
||||
const userDto = {
|
||||
...createUserDto,
|
||||
role: UserRole.USER,
|
||||
};
|
||||
|
||||
const user = await this.userService.create(userDto);
|
||||
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
};
|
||||
|
||||
return {
|
||||
access_token: this.jwtService.sign(payload),
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
24
backend/src/modules/auth/jwt-auth-public.guard.ts
Normal file
24
backend/src/modules/auth/jwt-auth-public.guard.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { IS_PUBLIC_KEY } from './public.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthPublicGuard extends AuthGuard('jwt') {
|
||||
constructor(private reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
||||
9
backend/src/modules/auth/jwt-auth.guard.ts
Normal file
9
backend/src/modules/auth/jwt-auth.guard.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
canActivate(context: ExecutionContext) {
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
||||
27
backend/src/modules/auth/jwt.strategy.ts
Normal file
27
backend/src/modules/auth/jwt.strategy.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtPayload } from './types';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey:
|
||||
configService.get<string>('JWT_SECRET') || 'default-secret-key',
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
await Promise.resolve(); // Add await to satisfy eslint rule
|
||||
return {
|
||||
id: payload.sub,
|
||||
username: payload.username,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
};
|
||||
}
|
||||
}
|
||||
5
backend/src/modules/auth/local-auth.guard.ts
Normal file
5
backend/src/modules/auth/local-auth.guard.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class LocalAuthGuard extends AuthGuard('local') {}
|
||||
22
backend/src/modules/auth/local.strategy.ts
Normal file
22
backend/src/modules/auth/local.strategy.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-local';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private authService: AuthService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async validate(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<{ id: string; username: string; email: string; role: string }> {
|
||||
const user = await this.authService.validateUser(username, password);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
4
backend/src/modules/auth/public.decorator.ts
Normal file
4
backend/src/modules/auth/public.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
4
backend/src/modules/auth/roles.decorator.ts
Normal file
4
backend/src/modules/auth/roles.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { UserRole } from '../entities';
|
||||
|
||||
export const Roles = (...roles: UserRole[]) => SetMetadata('roles', roles);
|
||||
31
backend/src/modules/auth/roles.guard.ts
Normal file
31
backend/src/modules/auth/roles.guard.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { UserRole } from '../entities';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
|
||||
'roles',
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!requiredRoles) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const request = context.switchToHttp().getRequest();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
const user = request.user as
|
||||
| { id: string; username: string; email: string; role: UserRole }
|
||||
| undefined;
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return requiredRoles.includes(user.role);
|
||||
}
|
||||
}
|
||||
27
backend/src/modules/auth/types.ts
Normal file
27
backend/src/modules/auth/types.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { UserRole } from '../entities';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
export interface RequestWithUser extends Request {
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
access_token: string;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
};
|
||||
}
|
||||
153
backend/src/modules/comment/comment.controller.ts
Normal file
153
backend/src/modules/comment/comment.controller.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Patch,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { CommentService } from './comment.service';
|
||||
import {
|
||||
CreateCommentDto,
|
||||
UpdateCommentDto,
|
||||
CommentResponseDto,
|
||||
FindCommentsDto,
|
||||
CreateReactionDto,
|
||||
} from './comment.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { Public } from '../auth/public.decorator';
|
||||
import { UserRole } from '../entities';
|
||||
import type { RequestWithUser } from '../auth/types';
|
||||
|
||||
@Controller('comments')
|
||||
export class CommentController {
|
||||
constructor(private readonly commentService: CommentService) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async create(
|
||||
@Body() createCommentDto: CreateCommentDto,
|
||||
@Request() req: RequestWithUser,
|
||||
): Promise<CommentResponseDto> {
|
||||
return this.commentService.create(createCommentDto, req.user.id);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Public()
|
||||
async findAll(
|
||||
@Query() findCommentsDto: FindCommentsDto,
|
||||
): Promise<CommentResponseDto[]> {
|
||||
return this.commentService.findAll(findCommentsDto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Public()
|
||||
async findOne(@Param('id') id: string): Promise<CommentResponseDto> {
|
||||
return this.commentService.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() updateCommentDto: UpdateCommentDto,
|
||||
@Request() req: RequestWithUser,
|
||||
): Promise<CommentResponseDto> {
|
||||
return this.commentService.update(
|
||||
id,
|
||||
updateCommentDto,
|
||||
req.user.id,
|
||||
req.user.role,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(
|
||||
@Param('id') id: string,
|
||||
@Request() req: RequestWithUser,
|
||||
): Promise<void> {
|
||||
return this.commentService.remove(id, req.user.id, req.user.role);
|
||||
}
|
||||
|
||||
@Post('reactions')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async addReaction(
|
||||
@Body() createReactionDto: CreateReactionDto,
|
||||
@Request() req: RequestWithUser,
|
||||
): Promise<void> {
|
||||
return this.commentService.addReaction(createReactionDto, req.user.id);
|
||||
}
|
||||
|
||||
@Get('reactions/user')
|
||||
@Public()
|
||||
async getUserReaction(
|
||||
@Request() req: RequestWithUser,
|
||||
@Query('articleId') articleId?: string,
|
||||
@Query('liveBlogId') liveBlogId?: string,
|
||||
@Query('commentId') commentId?: string,
|
||||
): Promise<{ type: string | null }> {
|
||||
// If user is not authenticated, return null
|
||||
if (!req.user?.id) {
|
||||
return { type: null };
|
||||
}
|
||||
|
||||
const reaction = await this.commentService.getUserReaction(
|
||||
req.user.id,
|
||||
articleId,
|
||||
liveBlogId,
|
||||
commentId,
|
||||
);
|
||||
return { type: reaction };
|
||||
}
|
||||
|
||||
@Get('reactions/counts')
|
||||
@Public()
|
||||
async getReactionCounts(
|
||||
@Query('articleId') articleId?: string,
|
||||
@Query('liveBlogId') liveBlogId?: string,
|
||||
@Query('commentId') commentId?: string,
|
||||
): Promise<{ likes: number; dislikes: number }> {
|
||||
return this.commentService.getReactionCounts(
|
||||
articleId,
|
||||
liveBlogId,
|
||||
commentId,
|
||||
);
|
||||
}
|
||||
|
||||
// Admin endpoints
|
||||
@Patch(':id/hide')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
async hideComment(@Param('id') id: string): Promise<CommentResponseDto> {
|
||||
return this.commentService.update(
|
||||
id,
|
||||
{ isVisible: false },
|
||||
'admin',
|
||||
'admin',
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':id/show')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
async showComment(@Param('id') id: string): Promise<CommentResponseDto> {
|
||||
return this.commentService.update(
|
||||
id,
|
||||
{ isVisible: true },
|
||||
'admin',
|
||||
'admin',
|
||||
);
|
||||
}
|
||||
}
|
||||
149
backend/src/modules/comment/comment.dto.ts
Normal file
149
backend/src/modules/comment/comment.dto.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
Min,
|
||||
Max,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
interface CommentEntity {
|
||||
id: string;
|
||||
content: string;
|
||||
articleId?: string;
|
||||
liveBlogId?: string;
|
||||
parentId?: string;
|
||||
userId: string;
|
||||
likeCount: number;
|
||||
dislikeCount: number;
|
||||
isVisible: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
replies?: CommentEntity[];
|
||||
}
|
||||
|
||||
export class CreateCommentDto {
|
||||
@IsString()
|
||||
@MaxLength(5000)
|
||||
content: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
articleId?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
liveBlogId?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
export class UpdateCommentDto {
|
||||
@IsString()
|
||||
@MaxLength(5000)
|
||||
@IsOptional()
|
||||
content?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
export class CommentResponseDto {
|
||||
id: string;
|
||||
content: string;
|
||||
articleId?: string;
|
||||
liveBlogId?: string;
|
||||
parentId?: string;
|
||||
userId: string;
|
||||
likeCount: number;
|
||||
dislikeCount: number;
|
||||
isVisible: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
replies?: CommentResponseDto[];
|
||||
|
||||
constructor(comment: CommentEntity) {
|
||||
this.id = comment.id;
|
||||
this.content = comment.content;
|
||||
this.articleId = comment.articleId;
|
||||
this.liveBlogId = comment.liveBlogId;
|
||||
this.parentId = comment.parentId;
|
||||
this.userId = comment.userId;
|
||||
this.likeCount = comment.likeCount;
|
||||
this.dislikeCount = comment.dislikeCount;
|
||||
this.isVisible = comment.isVisible;
|
||||
this.createdAt = comment.createdAt;
|
||||
this.updatedAt = comment.updatedAt;
|
||||
|
||||
if (comment.user) {
|
||||
this.user = {
|
||||
id: comment.user.id,
|
||||
username: comment.user.username,
|
||||
};
|
||||
}
|
||||
|
||||
if (comment.replies) {
|
||||
this.replies = comment.replies.map(
|
||||
(reply) => new CommentResponseDto(reply),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class FindCommentsDto {
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
articleId?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
liveBlogId?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
parentId?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
page?: number = 1;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
limit?: number = 20;
|
||||
}
|
||||
|
||||
export class CreateReactionDto {
|
||||
@IsString()
|
||||
type: 'like' | 'dislike';
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
articleId?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
liveBlogId?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
commentId?: string;
|
||||
}
|
||||
13
backend/src/modules/comment/comment.module.ts
Normal file
13
backend/src/modules/comment/comment.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Comment, Reaction } from '../entities';
|
||||
import { CommentService } from './comment.service';
|
||||
import { CommentController } from './comment.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Comment, Reaction])],
|
||||
controllers: [CommentController],
|
||||
providers: [CommentService],
|
||||
exports: [CommentService],
|
||||
})
|
||||
export class CommentModule {}
|
||||
298
backend/src/modules/comment/comment.service.ts
Normal file
298
backend/src/modules/comment/comment.service.ts
Normal file
@ -0,0 +1,298 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Comment, Reaction, ReactionType } from '../entities';
|
||||
import {
|
||||
CreateCommentDto,
|
||||
UpdateCommentDto,
|
||||
CommentResponseDto,
|
||||
FindCommentsDto,
|
||||
CreateReactionDto,
|
||||
} from './comment.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CommentService {
|
||||
constructor(
|
||||
@InjectRepository(Comment)
|
||||
private commentRepository: Repository<Comment>,
|
||||
@InjectRepository(Reaction)
|
||||
private reactionRepository: Repository<Reaction>,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
createCommentDto: CreateCommentDto,
|
||||
userId: string,
|
||||
): Promise<CommentResponseDto> {
|
||||
// Validate that comment is attached to either article or live blog
|
||||
if (!createCommentDto.articleId && !createCommentDto.liveBlogId) {
|
||||
throw new BadRequestException(
|
||||
'Comment must be attached to either an article or live blog',
|
||||
);
|
||||
}
|
||||
|
||||
// If parentId is provided, verify it exists
|
||||
if (createCommentDto.parentId) {
|
||||
const parent = await this.commentRepository.findOne({
|
||||
where: { id: createCommentDto.parentId },
|
||||
});
|
||||
if (!parent) {
|
||||
throw new NotFoundException('Parent comment not found');
|
||||
}
|
||||
}
|
||||
|
||||
const comment = this.commentRepository.create({
|
||||
...createCommentDto,
|
||||
userId,
|
||||
});
|
||||
|
||||
const savedComment = await this.commentRepository.save(comment);
|
||||
return this.findOne(savedComment.id);
|
||||
}
|
||||
|
||||
async findAll(
|
||||
findCommentsDto: FindCommentsDto,
|
||||
): Promise<CommentResponseDto[]> {
|
||||
const {
|
||||
articleId,
|
||||
liveBlogId,
|
||||
parentId,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
} = findCommentsDto;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const query = this.commentRepository
|
||||
.createQueryBuilder('comment')
|
||||
.leftJoinAndSelect('comment.user', 'user')
|
||||
.leftJoinAndSelect('comment.replies', 'replies')
|
||||
.leftJoinAndSelect('replies.user', 'replyUser')
|
||||
.where('comment.isVisible = :isVisible', { isVisible: true });
|
||||
|
||||
if (articleId) {
|
||||
query.andWhere('comment.articleId = :articleId', { articleId });
|
||||
}
|
||||
|
||||
if (liveBlogId) {
|
||||
query.andWhere('comment.liveBlogId = :liveBlogId', { liveBlogId });
|
||||
}
|
||||
|
||||
if (parentId) {
|
||||
query.andWhere('comment.parentId = :parentId', { parentId });
|
||||
} else {
|
||||
query.andWhere('comment.parentId IS NULL');
|
||||
}
|
||||
|
||||
query.orderBy('comment.createdAt', 'DESC').skip(skip).take(limit);
|
||||
|
||||
const comments = await query.getMany();
|
||||
return comments.map((comment) => new CommentResponseDto(comment));
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<CommentResponseDto> {
|
||||
const comment = await this.commentRepository
|
||||
.createQueryBuilder('comment')
|
||||
.leftJoinAndSelect('comment.user', 'user')
|
||||
.leftJoinAndSelect('comment.replies', 'replies')
|
||||
.leftJoinAndSelect('replies.user', 'replyUser')
|
||||
.where('comment.id = :id', { id })
|
||||
.andWhere('comment.isVisible = :isVisible', { isVisible: true })
|
||||
.getOne();
|
||||
|
||||
if (!comment) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
|
||||
return new CommentResponseDto(comment);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
updateCommentDto: UpdateCommentDto,
|
||||
userId: string,
|
||||
userRole: string,
|
||||
): Promise<CommentResponseDto> {
|
||||
const comment = await this.commentRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['user'],
|
||||
});
|
||||
|
||||
if (!comment) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
|
||||
// Only allow comment owner or admin to update
|
||||
if (comment.userId !== userId && userRole !== 'admin') {
|
||||
throw new BadRequestException('You can only update your own comments');
|
||||
}
|
||||
|
||||
Object.assign(comment, updateCommentDto);
|
||||
const updatedComment = await this.commentRepository.save(comment);
|
||||
return this.findOne(updatedComment.id);
|
||||
}
|
||||
|
||||
async remove(id: string, userId: string, userRole: string): Promise<void> {
|
||||
const comment = await this.commentRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['user'],
|
||||
});
|
||||
|
||||
if (!comment) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
|
||||
// Only allow comment owner or admin to delete
|
||||
if (comment.userId !== userId && userRole !== 'admin') {
|
||||
throw new BadRequestException('You can only delete your own comments');
|
||||
}
|
||||
|
||||
await this.commentRepository.remove(comment);
|
||||
}
|
||||
|
||||
async addReaction(
|
||||
createReactionDto: CreateReactionDto,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
// Validate that reaction is attached to either article, live blog, or comment
|
||||
const { articleId, liveBlogId, commentId, type } = createReactionDto;
|
||||
|
||||
if (!articleId && !liveBlogId && !commentId) {
|
||||
throw new BadRequestException(
|
||||
'Reaction must be attached to an article, live blog, or comment',
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user already reacted to this item
|
||||
const whereClause: {
|
||||
userId: string;
|
||||
articleId?: string;
|
||||
liveBlogId?: string;
|
||||
commentId?: string;
|
||||
} = { userId };
|
||||
if (articleId) whereClause.articleId = articleId;
|
||||
if (liveBlogId) whereClause.liveBlogId = liveBlogId;
|
||||
if (commentId) whereClause.commentId = commentId;
|
||||
|
||||
const existingReaction = await this.reactionRepository.findOne({
|
||||
where: whereClause,
|
||||
});
|
||||
|
||||
if (existingReaction) {
|
||||
// If same reaction type, remove it (toggle)
|
||||
if (existingReaction.type === (type as ReactionType)) {
|
||||
await this.reactionRepository.remove(existingReaction);
|
||||
|
||||
// Update comment counts if reacting to comment
|
||||
if (commentId) {
|
||||
await this.updateCommentReactionCount(commentId, type, -1);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
// Change reaction type
|
||||
existingReaction.type = type as ReactionType;
|
||||
await this.reactionRepository.save(existingReaction);
|
||||
|
||||
// Update comment counts if reacting to comment
|
||||
if (commentId) {
|
||||
// Decrement old reaction count
|
||||
await this.updateCommentReactionCount(
|
||||
commentId,
|
||||
existingReaction.type === ReactionType.LIKE
|
||||
? ReactionType.DISLIKE
|
||||
: ReactionType.LIKE,
|
||||
-1,
|
||||
);
|
||||
// Increment new reaction count
|
||||
await this.updateCommentReactionCount(commentId, type, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new reaction
|
||||
const reaction = this.reactionRepository.create({
|
||||
...createReactionDto,
|
||||
userId,
|
||||
type: type as ReactionType,
|
||||
});
|
||||
|
||||
await this.reactionRepository.save(reaction);
|
||||
|
||||
// Update comment counts if reacting to comment
|
||||
if (commentId) {
|
||||
await this.updateCommentReactionCount(commentId, type, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateCommentReactionCount(
|
||||
commentId: string,
|
||||
type: 'like' | 'dislike',
|
||||
change: number,
|
||||
): Promise<void> {
|
||||
const comment = await this.commentRepository.findOne({
|
||||
where: { id: commentId },
|
||||
});
|
||||
if (!comment) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'like') {
|
||||
comment.likeCount = Math.max(0, comment.likeCount + change);
|
||||
} else {
|
||||
comment.dislikeCount = Math.max(0, comment.dislikeCount + change);
|
||||
}
|
||||
|
||||
await this.commentRepository.save(comment);
|
||||
}
|
||||
|
||||
async getUserReaction(
|
||||
userId: string,
|
||||
articleId?: string,
|
||||
liveBlogId?: string,
|
||||
commentId?: string,
|
||||
): Promise<ReactionType | null> {
|
||||
const whereClause: {
|
||||
userId: string;
|
||||
articleId?: string;
|
||||
liveBlogId?: string;
|
||||
commentId?: string;
|
||||
} = { userId };
|
||||
if (articleId) whereClause.articleId = articleId;
|
||||
if (liveBlogId) whereClause.liveBlogId = liveBlogId;
|
||||
if (commentId) whereClause.commentId = commentId;
|
||||
|
||||
const reaction = await this.reactionRepository.findOne({
|
||||
where: whereClause,
|
||||
});
|
||||
|
||||
return reaction ? reaction.type : null;
|
||||
}
|
||||
|
||||
async getReactionCounts(
|
||||
articleId?: string,
|
||||
liveBlogId?: string,
|
||||
commentId?: string,
|
||||
): Promise<{ likes: number; dislikes: number }> {
|
||||
const query = this.reactionRepository.createQueryBuilder('reaction');
|
||||
|
||||
if (articleId) {
|
||||
query.where('reaction.articleId = :articleId', { articleId });
|
||||
} else if (liveBlogId) {
|
||||
query.where('reaction.liveBlogId = :liveBlogId', { liveBlogId });
|
||||
} else if (commentId) {
|
||||
query.where('reaction.commentId = :commentId', { commentId });
|
||||
} else {
|
||||
return { likes: 0, dislikes: 0 };
|
||||
}
|
||||
|
||||
const reactions = await query.getMany();
|
||||
|
||||
return {
|
||||
likes: reactions.filter((r) => r.type === ReactionType.LIKE).length,
|
||||
dislikes: reactions.filter((r) => r.type === ReactionType.DISLIKE).length,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@ import {
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
ValueTransformer,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
|
||||
class ArrayTransformer implements ValueTransformer {
|
||||
@ -15,7 +16,7 @@ class ArrayTransformer implements ValueTransformer {
|
||||
}
|
||||
from(value: string): string[] {
|
||||
try {
|
||||
return value ? JSON.parse(value) : [];
|
||||
return value ? (JSON.parse(value) as string[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@ -28,6 +29,44 @@ export enum ArticleStatus {
|
||||
ARCHIVED = 'archived',
|
||||
}
|
||||
|
||||
export enum LiveBlogStatus {
|
||||
DRAFT = 'draft',
|
||||
LIVE = 'live',
|
||||
ENDED = 'ended',
|
||||
ARCHIVED = 'archived',
|
||||
}
|
||||
|
||||
export enum ImagePosition {
|
||||
TOP = 'top',
|
||||
LEFT = 'left',
|
||||
RIGHT = 'right',
|
||||
NONE = 'none',
|
||||
}
|
||||
|
||||
export enum ImageSize {
|
||||
SMALL = 'small',
|
||||
MEDIUM = 'medium',
|
||||
LARGE = 'large',
|
||||
}
|
||||
|
||||
export enum VideoPosition {
|
||||
TOP = 'top',
|
||||
INLINE = 'inline',
|
||||
BOTTOM = 'bottom',
|
||||
NONE = 'none',
|
||||
}
|
||||
|
||||
export enum UserRole {
|
||||
ADMIN = 'admin',
|
||||
CONTRIBUTOR = 'contributor',
|
||||
USER = 'user',
|
||||
}
|
||||
|
||||
export enum ReactionType {
|
||||
LIKE = 'like',
|
||||
DISLIKE = 'dislike',
|
||||
}
|
||||
|
||||
@Entity('authors')
|
||||
export class Author {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
@ -86,6 +125,36 @@ export class Category {
|
||||
parent: Category;
|
||||
}
|
||||
|
||||
@Entity('users')
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
email: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
username: string;
|
||||
|
||||
@Column()
|
||||
passwordHash: string;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
default: 'user',
|
||||
})
|
||||
role: UserRole;
|
||||
|
||||
@Column({ default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@Entity('articles')
|
||||
export class Article {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
@ -106,6 +175,30 @@ export class Article {
|
||||
@Column({ default: '' })
|
||||
featuredImage: string;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
default: 'top',
|
||||
})
|
||||
imagePosition: ImagePosition;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
default: 'medium',
|
||||
})
|
||||
imageSize: ImageSize;
|
||||
|
||||
@Column({ default: '' })
|
||||
videoUrl: string;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
default: 'inline',
|
||||
})
|
||||
videoPosition: VideoPosition;
|
||||
|
||||
@Column({ default: '' })
|
||||
videoCaption: string;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
default: '[]',
|
||||
@ -122,7 +215,7 @@ export class Article {
|
||||
@Column({ default: 0 })
|
||||
views: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@Column({ nullable: true, unique: true })
|
||||
strapiId: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@ -131,6 +224,42 @@ export class Article {
|
||||
@Column({ nullable: true })
|
||||
categoryId: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
ogTitle: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
ogDescription: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
ogImage: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
twitterTitle: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
twitterDescription: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
twitterImage: string;
|
||||
|
||||
@Column({ default: 0 })
|
||||
facebookShares: number;
|
||||
|
||||
@Column({ default: 0 })
|
||||
twitterShares: number;
|
||||
|
||||
@Column({ default: 0 })
|
||||
instagramShares: number;
|
||||
|
||||
@Column({ default: 0 })
|
||||
tiktokShares: number;
|
||||
|
||||
@Column({ default: 0 })
|
||||
telegramShares: number;
|
||||
|
||||
@Column({ default: false })
|
||||
isHero: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@ -144,4 +273,230 @@ export class Article {
|
||||
@ManyToOne(() => Category, { onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'categoryId' })
|
||||
category: Category;
|
||||
|
||||
@OneToMany(() => Comment, (comment) => comment.article)
|
||||
comments: Comment[];
|
||||
|
||||
@OneToMany(() => Reaction, (reaction) => reaction.article)
|
||||
reactions: Reaction[];
|
||||
}
|
||||
|
||||
@Entity('live_blogs')
|
||||
export class LiveBlog {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
title: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
slug: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
default: 'draft',
|
||||
})
|
||||
status: LiveBlogStatus;
|
||||
|
||||
@Column({ default: false })
|
||||
isPinned: boolean;
|
||||
|
||||
@Column({ nullable: true, unique: true })
|
||||
strapiId: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
authorId: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
categoryId: string;
|
||||
|
||||
@Column({ default: '' })
|
||||
featuredImage: string;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
default: 'top',
|
||||
})
|
||||
imagePosition: ImagePosition;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
default: 'medium',
|
||||
})
|
||||
imageSize: ImageSize;
|
||||
|
||||
@Column({ default: '' })
|
||||
videoUrl: string;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
default: 'inline',
|
||||
})
|
||||
videoPosition: VideoPosition;
|
||||
|
||||
@Column({ default: '' })
|
||||
videoCaption: string;
|
||||
|
||||
@Column({ default: 0 })
|
||||
viewCount: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
@ManyToOne(() => Author, { onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'authorId' })
|
||||
author: Author;
|
||||
|
||||
@ManyToOne(() => Category, { onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'categoryId' })
|
||||
category: Category;
|
||||
|
||||
@OneToMany(() => LiveBlogUpdate, (update) => update.liveBlog, {
|
||||
cascade: true,
|
||||
})
|
||||
updates: LiveBlogUpdate[];
|
||||
|
||||
@OneToMany(() => Comment, (comment) => comment.liveBlog)
|
||||
comments: Comment[];
|
||||
|
||||
@OneToMany(() => Reaction, (reaction) => reaction.liveBlog)
|
||||
reactions: Reaction[];
|
||||
}
|
||||
|
||||
@Entity('live_blog_updates')
|
||||
export class LiveBlogUpdate {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column('text')
|
||||
content: string;
|
||||
|
||||
@Column({ default: false })
|
||||
isPinned: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
authorId: string;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
scheduledAt: Date;
|
||||
|
||||
@Column({ nullable: true })
|
||||
strapiId: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
@ManyToOne(() => Author, { onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'authorId' })
|
||||
author: Author;
|
||||
|
||||
@ManyToOne(() => LiveBlog, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'liveBlogId' })
|
||||
liveBlog: LiveBlog;
|
||||
}
|
||||
|
||||
@Entity('comments')
|
||||
export class Comment {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column('text')
|
||||
content: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
articleId: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
liveBlogId: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
parentId: string;
|
||||
|
||||
@Column()
|
||||
userId: string;
|
||||
|
||||
@Column({ default: 0 })
|
||||
likeCount: number;
|
||||
|
||||
@Column({ default: 0 })
|
||||
dislikeCount: number;
|
||||
|
||||
@Column({ default: true })
|
||||
isVisible: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
@ManyToOne(() => Article, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'articleId' })
|
||||
article: Article;
|
||||
|
||||
@ManyToOne(() => LiveBlog, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'liveBlogId' })
|
||||
liveBlog: LiveBlog;
|
||||
|
||||
@ManyToOne(() => Comment, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'parentId' })
|
||||
parent: Comment;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@OneToMany(() => Comment, (comment) => comment.parent)
|
||||
replies: Comment[];
|
||||
}
|
||||
|
||||
@Entity('reactions')
|
||||
export class Reaction {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
})
|
||||
type: ReactionType;
|
||||
|
||||
@Column({ nullable: true })
|
||||
articleId: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
liveBlogId: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
commentId: string;
|
||||
|
||||
@Column()
|
||||
userId: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@ManyToOne(() => Article, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'articleId' })
|
||||
article: Article;
|
||||
|
||||
@ManyToOne(() => LiveBlog, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'liveBlogId' })
|
||||
liveBlog: LiveBlog;
|
||||
|
||||
@ManyToOne(() => Comment, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'commentId' })
|
||||
comment: Comment;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
}
|
||||
|
||||
172
backend/src/modules/live-blog.controller.ts
Normal file
172
backend/src/modules/live-blog.controller.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Patch,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
Res,
|
||||
Logger,
|
||||
Headers,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Response as ExpressResponse } from 'express';
|
||||
import { LiveBlogService } from './live-blog.service';
|
||||
import {
|
||||
CreateLiveBlogDto,
|
||||
UpdateLiveBlogDto,
|
||||
FindLiveBlogsDto,
|
||||
CreateLiveBlogUpdateDto,
|
||||
UpdateLiveBlogUpdateDto,
|
||||
} from './articles.dto';
|
||||
import { LiveBlogStatus, UserRole } from './entities';
|
||||
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||
import { RolesGuard } from './auth/roles.guard';
|
||||
import { Roles } from './auth/roles.decorator';
|
||||
import { Public } from './auth/public.decorator';
|
||||
|
||||
@Controller('live-blogs')
|
||||
export class LiveBlogController {
|
||||
private readonly logger = new Logger(LiveBlogController.name);
|
||||
|
||||
constructor(private readonly liveBlogService: LiveBlogService) {}
|
||||
|
||||
// Live Blog CRUD operations
|
||||
@Get('featured')
|
||||
@Public()
|
||||
getFeatured() {
|
||||
this.logger.log('GET /featured called');
|
||||
return this.liveBlogService.findPinned();
|
||||
}
|
||||
|
||||
@Get('active')
|
||||
@Public()
|
||||
getActive() {
|
||||
return this.liveBlogService.findActive();
|
||||
}
|
||||
|
||||
@Get('recent')
|
||||
@Public()
|
||||
getRecent() {
|
||||
return this.liveBlogService.getLiveBlogsWithRecentUpdates();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
create(@Body() dto: CreateLiveBlogDto) {
|
||||
return this.liveBlogService.create(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Public()
|
||||
findAll(@Query() dto: FindLiveBlogsDto) {
|
||||
return this.liveBlogService.findAll(dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Public()
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.liveBlogService.findOne(id);
|
||||
}
|
||||
|
||||
@Get('slug/:slug')
|
||||
@Public()
|
||||
findBySlug(@Param('slug') slug: string) {
|
||||
return this.liveBlogService.findBySlug(slug);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
update(@Param('id') id: string, @Body() dto: UpdateLiveBlogDto) {
|
||||
return this.liveBlogService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
remove(@Param('id') id: string) {
|
||||
return this.liveBlogService.remove(id);
|
||||
}
|
||||
|
||||
// Live Blog Updates CRUD operations
|
||||
@Post(':id/updates')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
createUpdate(
|
||||
@Param('id') liveBlogId: string,
|
||||
@Body() dto: CreateLiveBlogUpdateDto,
|
||||
) {
|
||||
return this.liveBlogService.createUpdate(dto, liveBlogId);
|
||||
}
|
||||
|
||||
@Get(':id/updates')
|
||||
@Public()
|
||||
findUpdates(
|
||||
@Param('id') liveBlogId: string,
|
||||
@Query('page') page = 1,
|
||||
@Query('limit') limit = 50,
|
||||
) {
|
||||
return this.liveBlogService.findUpdates(
|
||||
liveBlogId,
|
||||
parseInt(page.toString()),
|
||||
parseInt(limit.toString()),
|
||||
);
|
||||
}
|
||||
|
||||
@Put(':id/updates/:updateId')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
updateUpdate(
|
||||
@Param('id') liveBlogId: string,
|
||||
@Param('updateId') updateId: string,
|
||||
@Body() dto: UpdateLiveBlogUpdateDto,
|
||||
) {
|
||||
return this.liveBlogService.updateUpdate(liveBlogId, updateId, dto);
|
||||
}
|
||||
|
||||
@Delete(':id/updates/:updateId')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
removeUpdate(
|
||||
@Param('id') liveBlogId: string,
|
||||
@Param('updateId') updateId: string,
|
||||
) {
|
||||
return this.liveBlogService.removeUpdate(liveBlogId, updateId);
|
||||
}
|
||||
|
||||
@Patch(':id/archive')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
archive(@Param('id') id: string) {
|
||||
return this.liveBlogService.archive(id);
|
||||
}
|
||||
|
||||
@Patch(':id/publish')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
publish(@Param('id') id: string, @Query('status') status?: LiveBlogStatus) {
|
||||
return this.liveBlogService.publish(id, status || LiveBlogStatus.DRAFT);
|
||||
}
|
||||
|
||||
// SSE endpoint for real-time updates
|
||||
@Get(':id/stream')
|
||||
@Public()
|
||||
stream(
|
||||
@Param('id') liveBlogId: string,
|
||||
@Res() response: ExpressResponse,
|
||||
@Headers('last-event-id') lastEventId?: string,
|
||||
) {
|
||||
this.logger.log(`SSE connection request for live blog ${liveBlogId}`);
|
||||
|
||||
if (lastEventId) {
|
||||
this.logger.log(`Client resuming with last event ID: ${lastEventId}`);
|
||||
}
|
||||
|
||||
this.liveBlogService.createStream(liveBlogId, response);
|
||||
}
|
||||
}
|
||||
21
backend/src/modules/live-blog.module.ts
Normal file
21
backend/src/modules/live-blog.module.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { LiveBlogService } from './live-blog.service';
|
||||
import { LiveBlogController } from './live-blog.controller';
|
||||
import { LiveBlog, LiveBlogUpdate, Author, Category } from './entities';
|
||||
import { StrapiModule } from './strapi.module';
|
||||
import { PushModule } from './push/push.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([LiveBlog, LiveBlogUpdate, Author, Category]),
|
||||
EventEmitterModule.forRoot(),
|
||||
forwardRef(() => StrapiModule),
|
||||
forwardRef(() => PushModule),
|
||||
],
|
||||
controllers: [LiveBlogController],
|
||||
providers: [LiveBlogService],
|
||||
exports: [LiveBlogService],
|
||||
})
|
||||
export class LiveBlogModule {}
|
||||
653
backend/src/modules/live-blog.service.ts
Normal file
653
backend/src/modules/live-blog.service.ts
Normal file
@ -0,0 +1,653 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
Inject,
|
||||
forwardRef,
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In } from 'typeorm';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Response } from 'express';
|
||||
import { LiveBlog, LiveBlogUpdate, LiveBlogStatus } from './entities';
|
||||
import { StrapiService } from './strapi.service';
|
||||
import {
|
||||
CreateLiveBlogDto,
|
||||
UpdateLiveBlogDto,
|
||||
FindLiveBlogsDto,
|
||||
CreateLiveBlogUpdateDto,
|
||||
UpdateLiveBlogUpdateDto,
|
||||
} from './articles.dto';
|
||||
import { PushService } from './push/push.service';
|
||||
|
||||
interface SseClient {
|
||||
id: string;
|
||||
response: Response;
|
||||
blogId: string;
|
||||
}
|
||||
|
||||
interface LiveBlogUpdateEvent {
|
||||
blogId: string;
|
||||
update: LiveBlogUpdate;
|
||||
}
|
||||
|
||||
interface LiveBlogStatusChangeEvent {
|
||||
blogId: string;
|
||||
status: LiveBlogStatus;
|
||||
}
|
||||
|
||||
interface LiveBlogPinUpdateEvent {
|
||||
blogId: string;
|
||||
updateId: string;
|
||||
isPinned: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LiveBlogService implements OnModuleInit {
|
||||
private readonly logger = new Logger(LiveBlogService.name);
|
||||
private readonly sseClients = new Map<string, SseClient>();
|
||||
|
||||
constructor(
|
||||
@InjectRepository(LiveBlog)
|
||||
private readonly liveBlogRepository: Repository<LiveBlog>,
|
||||
@InjectRepository(LiveBlogUpdate)
|
||||
private readonly liveBlogUpdateRepository: Repository<LiveBlogUpdate>,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
@Inject(forwardRef(() => StrapiService))
|
||||
private readonly strapiService: StrapiService,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => PushService))
|
||||
private readonly pushService?: PushService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.eventEmitter.on('live-blog.update', (data: LiveBlogUpdateEvent) => {
|
||||
this.broadcastToClients(data.blogId, {
|
||||
type: 'update',
|
||||
data: data.update,
|
||||
});
|
||||
});
|
||||
|
||||
this.eventEmitter.on(
|
||||
'live-blog.status-change',
|
||||
(data: LiveBlogStatusChangeEvent) => {
|
||||
this.broadcastToClients(data.blogId, {
|
||||
type: 'status-change',
|
||||
data: { status: data.status },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
this.eventEmitter.on(
|
||||
'live-blog.pin-update',
|
||||
(data: LiveBlogPinUpdateEvent) => {
|
||||
this.broadcastToClients(data.blogId, {
|
||||
type: 'pin-update',
|
||||
data: { updateId: data.updateId, isPinned: data.isPinned },
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Live Blog CRUD operations
|
||||
async create(dto: CreateLiveBlogDto): Promise<LiveBlog> {
|
||||
const liveBlog = this.liveBlogRepository.create({
|
||||
...dto,
|
||||
status: dto.status || LiveBlogStatus.DRAFT,
|
||||
});
|
||||
return await this.liveBlogRepository.save(liveBlog);
|
||||
}
|
||||
|
||||
async findAll(
|
||||
dto: FindLiveBlogsDto,
|
||||
): Promise<{ data: LiveBlog[]; total: number }> {
|
||||
const {
|
||||
category,
|
||||
author,
|
||||
status,
|
||||
isPinned,
|
||||
search,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
} = dto;
|
||||
|
||||
const queryBuilder = this.liveBlogRepository
|
||||
.createQueryBuilder('liveBlog')
|
||||
.leftJoinAndSelect('liveBlog.author', 'author')
|
||||
.leftJoinAndSelect('liveBlog.category', 'category');
|
||||
|
||||
// Handle status filter - can be single value or comma-separated list
|
||||
if (status) {
|
||||
if (typeof status === 'string' && status.includes(',')) {
|
||||
const statuses = status.split(',').map((s) => s.trim());
|
||||
queryBuilder.where('liveBlog.status IN (:...statuses)', { statuses });
|
||||
} else {
|
||||
queryBuilder.where('liveBlog.status = :status', { status });
|
||||
}
|
||||
}
|
||||
// If no status specified, return all live blogs (for admin dashboard)
|
||||
// Note: Pinned blogs query should still work without status filter
|
||||
|
||||
if (category) {
|
||||
queryBuilder.andWhere('category.slug = :category', { category });
|
||||
}
|
||||
|
||||
if (author) {
|
||||
queryBuilder.andWhere('author.slug = :author', { author });
|
||||
}
|
||||
|
||||
if (search) {
|
||||
queryBuilder.andWhere(
|
||||
'(liveBlog.title ILIKE :search OR liveBlog.description ILIKE :search)',
|
||||
{ search: `%${search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
if (isPinned !== undefined) {
|
||||
queryBuilder.andWhere('liveBlog.isPinned = :isPinned', { isPinned });
|
||||
}
|
||||
|
||||
const [data, total] = await queryBuilder
|
||||
.orderBy('liveBlog.createdAt', 'DESC')
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<LiveBlog> {
|
||||
const liveBlog = await this.liveBlogRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['author', 'category', 'updates'],
|
||||
});
|
||||
|
||||
if (!liveBlog) {
|
||||
throw new NotFoundException(`Live blog with ID ${id} not found`);
|
||||
}
|
||||
|
||||
// Increment view count
|
||||
await this.liveBlogRepository.increment({ id }, 'viewCount', 1);
|
||||
|
||||
return liveBlog;
|
||||
}
|
||||
|
||||
async findOneWithoutIncrement(id: string): Promise<LiveBlog> {
|
||||
const liveBlog = await this.liveBlogRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['author', 'category', 'updates'],
|
||||
});
|
||||
|
||||
if (!liveBlog) {
|
||||
throw new NotFoundException(`Live blog with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return liveBlog;
|
||||
}
|
||||
|
||||
async findBySlug(slug: string): Promise<LiveBlog> {
|
||||
const liveBlog = await this.liveBlogRepository.findOne({
|
||||
where: { slug },
|
||||
relations: ['author', 'category', 'updates'],
|
||||
});
|
||||
|
||||
if (!liveBlog) {
|
||||
throw new NotFoundException(`Live blog with slug ${slug} not found`);
|
||||
}
|
||||
|
||||
// Increment view count
|
||||
await this.liveBlogRepository.increment({ slug }, 'viewCount', 1);
|
||||
|
||||
return liveBlog;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateLiveBlogDto): Promise<LiveBlog> {
|
||||
const liveBlog = await this.liveBlogRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['author', 'category'],
|
||||
});
|
||||
|
||||
if (!liveBlog) {
|
||||
throw new NotFoundException(`Live blog with ID ${id} not found`);
|
||||
}
|
||||
|
||||
// Track if status changed for event emission
|
||||
const oldStatus = liveBlog.status;
|
||||
|
||||
// Update fields
|
||||
if (dto.title !== undefined) {
|
||||
liveBlog.title = dto.title;
|
||||
}
|
||||
|
||||
if (dto.slug !== undefined) {
|
||||
liveBlog.slug = dto.slug;
|
||||
}
|
||||
|
||||
if (dto.description !== undefined) {
|
||||
liveBlog.description = dto.description;
|
||||
}
|
||||
|
||||
if (dto.status !== undefined) {
|
||||
liveBlog.status = dto.status;
|
||||
}
|
||||
|
||||
if (dto.strapiId !== undefined) {
|
||||
liveBlog.strapiId = dto.strapiId;
|
||||
}
|
||||
|
||||
if (dto.authorId !== undefined) {
|
||||
liveBlog.authorId = dto.authorId;
|
||||
}
|
||||
|
||||
if (dto.categoryId !== undefined) {
|
||||
liveBlog.categoryId = dto.categoryId;
|
||||
}
|
||||
|
||||
if (dto.isPinned !== undefined) {
|
||||
liveBlog.isPinned = dto.isPinned;
|
||||
}
|
||||
|
||||
// Save the updated entity
|
||||
const updatedBlog = await this.liveBlogRepository.save(liveBlog);
|
||||
|
||||
// Emit status change event if status changed
|
||||
if (dto.status !== undefined && dto.status !== oldStatus) {
|
||||
this.eventEmitter.emit('live-blog.status-change', {
|
||||
blogId: id,
|
||||
status: dto.status,
|
||||
});
|
||||
|
||||
// Update Strapi if live blog has strapiId
|
||||
if (liveBlog.strapiId) {
|
||||
try {
|
||||
await this.strapiService.updateLiveBlogStatusInStrapi(
|
||||
liveBlog.strapiId,
|
||||
dto.status,
|
||||
);
|
||||
} catch (error) {
|
||||
// Log error but don't fail - backend status is updated
|
||||
this.logger.error(
|
||||
`Failed to update Strapi status for live blog ${id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updatedBlog;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const liveBlog = await this.findOne(id);
|
||||
|
||||
// Delete from Strapi if live blog has strapiId
|
||||
if (liveBlog.strapiId) {
|
||||
try {
|
||||
await this.strapiService.deleteLiveBlogFromStrapi(liveBlog.strapiId);
|
||||
} catch (error) {
|
||||
// Log error but don't fail - we still want to delete from backend
|
||||
this.logger.error(
|
||||
`Failed to delete live blog ${id} from Strapi:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.liveBlogRepository.remove(liveBlog);
|
||||
}
|
||||
|
||||
async archive(id: string): Promise<LiveBlog> {
|
||||
const liveBlog = await this.findOne(id);
|
||||
liveBlog.status = LiveBlogStatus.ARCHIVED;
|
||||
const savedLiveBlog = await this.liveBlogRepository.save(liveBlog);
|
||||
|
||||
// Update Strapi if live blog has strapiId
|
||||
if (liveBlog.strapiId) {
|
||||
try {
|
||||
await this.strapiService.updateLiveBlogStatusInStrapi(
|
||||
liveBlog.strapiId,
|
||||
LiveBlogStatus.ARCHIVED,
|
||||
);
|
||||
} catch (error) {
|
||||
// Log error but don't fail - backend status is updated
|
||||
this.logger.error(
|
||||
`Failed to update Strapi status for live blog ${id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return savedLiveBlog;
|
||||
}
|
||||
|
||||
async publish(
|
||||
id: string,
|
||||
status: LiveBlogStatus = LiveBlogStatus.DRAFT,
|
||||
): Promise<LiveBlog> {
|
||||
const liveBlog = await this.findOne(id);
|
||||
liveBlog.status = status;
|
||||
const savedLiveBlog = await this.liveBlogRepository.save(liveBlog);
|
||||
|
||||
// Update Strapi if live blog has strapiId
|
||||
if (liveBlog.strapiId) {
|
||||
try {
|
||||
await this.strapiService.updateLiveBlogStatusInStrapi(
|
||||
liveBlog.strapiId,
|
||||
status,
|
||||
);
|
||||
} catch (error) {
|
||||
// Log error but don't fail - backend status is updated
|
||||
this.logger.error(
|
||||
`Failed to update Strapi status for live blog ${id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return savedLiveBlog;
|
||||
}
|
||||
|
||||
// Live Blog Update CRUD operations
|
||||
async createUpdate(
|
||||
dto: CreateLiveBlogUpdateDto,
|
||||
liveBlogId: string,
|
||||
): Promise<LiveBlogUpdate> {
|
||||
const liveBlogEntity = await this.findOne(liveBlogId);
|
||||
|
||||
const update = this.liveBlogUpdateRepository.create({
|
||||
...dto,
|
||||
liveBlog: liveBlogEntity,
|
||||
});
|
||||
|
||||
const savedUpdate = await this.liveBlogUpdateRepository.save(update);
|
||||
|
||||
// Emit update event
|
||||
this.eventEmitter.emit('live-blog.update', {
|
||||
blogId: liveBlogId,
|
||||
update: savedUpdate,
|
||||
});
|
||||
|
||||
// Send push notification for live blog updates (only for live blogs)
|
||||
if (liveBlogEntity.status === LiveBlogStatus.LIVE && this.pushService) {
|
||||
try {
|
||||
await this.pushService.notifyLiveBlogUpdate(
|
||||
liveBlogEntity.title,
|
||||
liveBlogEntity.slug,
|
||||
savedUpdate.content,
|
||||
);
|
||||
this.logger.debug(
|
||||
`Push notification sent for live blog update: ${liveBlogEntity.title}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to send push notification:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return savedUpdate;
|
||||
}
|
||||
|
||||
async findUpdates(
|
||||
liveBlogId: string,
|
||||
page = 1,
|
||||
limit = 50,
|
||||
): Promise<{ data: LiveBlogUpdate[]; total: number }> {
|
||||
const [data, total] = await this.liveBlogUpdateRepository.findAndCount({
|
||||
where: { liveBlog: { id: liveBlogId } },
|
||||
relations: ['author'],
|
||||
order: {
|
||||
isPinned: 'DESC',
|
||||
createdAt: 'ASC',
|
||||
},
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
async updateUpdate(
|
||||
liveBlogId: string,
|
||||
updateId: string,
|
||||
dto: UpdateLiveBlogUpdateDto,
|
||||
): Promise<LiveBlogUpdate> {
|
||||
const update = await this.liveBlogUpdateRepository.findOne({
|
||||
where: { id: updateId, liveBlog: { id: liveBlogId } },
|
||||
relations: ['liveBlog'],
|
||||
});
|
||||
|
||||
if (!update) {
|
||||
throw new NotFoundException(`Update with ID ${updateId} not found`);
|
||||
}
|
||||
|
||||
Object.assign(update, dto);
|
||||
const savedUpdate = await this.liveBlogUpdateRepository.save(update);
|
||||
|
||||
// Emit pin change event
|
||||
if (dto.isPinned !== undefined) {
|
||||
this.eventEmitter.emit('live-blog.pin-update', {
|
||||
blogId: liveBlogId,
|
||||
updateId,
|
||||
isPinned: dto.isPinned,
|
||||
});
|
||||
}
|
||||
|
||||
return savedUpdate;
|
||||
}
|
||||
|
||||
async removeUpdate(liveBlogId: string, updateId: string): Promise<void> {
|
||||
const update = await this.liveBlogUpdateRepository.findOne({
|
||||
where: { id: updateId, liveBlog: { id: liveBlogId } },
|
||||
});
|
||||
|
||||
if (!update) {
|
||||
throw new NotFoundException(`Update with ID ${updateId} not found`);
|
||||
}
|
||||
|
||||
await this.liveBlogUpdateRepository.remove(update);
|
||||
}
|
||||
|
||||
// SSE operations
|
||||
createStream(liveBlogId: string, response: Response): void {
|
||||
// Verify live blog exists and is live
|
||||
this.findOne(liveBlogId).catch(() => {
|
||||
response.end();
|
||||
return;
|
||||
});
|
||||
|
||||
const clientId = `${Date.now()}-${Math.random()}`;
|
||||
|
||||
// Set SSE headers
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Cache-Control',
|
||||
});
|
||||
|
||||
// Send initial connection message
|
||||
response.write(
|
||||
`data: ${JSON.stringify({ type: 'connected', clientId })}\n\n`,
|
||||
);
|
||||
|
||||
// Store client connection
|
||||
this.sseClients.set(clientId, {
|
||||
id: clientId,
|
||||
response,
|
||||
blogId: liveBlogId,
|
||||
});
|
||||
|
||||
// Send periodic keep-alive messages to prevent timeout
|
||||
const keepAliveInterval = setInterval(() => {
|
||||
try {
|
||||
response.write(`: keep-alive\n\n`);
|
||||
} catch {
|
||||
// Client disconnected, stop sending keep-alive
|
||||
clearInterval(keepAliveInterval);
|
||||
}
|
||||
}, 15000); // Send keep-alive every 15 seconds
|
||||
|
||||
// Handle client disconnect
|
||||
response.on('close', () => {
|
||||
clearInterval(keepAliveInterval);
|
||||
this.sseClients.delete(clientId);
|
||||
this.logger.log(
|
||||
`Client ${clientId} disconnected from live blog ${liveBlogId}`,
|
||||
);
|
||||
});
|
||||
|
||||
this.logger.log(`Client ${clientId} connected to live blog ${liveBlogId}`);
|
||||
}
|
||||
|
||||
private broadcastToClients(liveBlogId: string, message: any): void {
|
||||
const clients = Array.from(this.sseClients.values()).filter(
|
||||
(client) => client.blogId === liveBlogId,
|
||||
);
|
||||
|
||||
clients.forEach((client) => {
|
||||
try {
|
||||
client.response.write(`data: ${JSON.stringify(message)}\n\n`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to send message to client ${client.id}:`,
|
||||
error,
|
||||
);
|
||||
this.sseClients.delete(client.id);
|
||||
}
|
||||
});
|
||||
|
||||
this.logger.debug(
|
||||
`Broadcasted message to ${clients.length} clients for live blog ${liveBlogId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Strapi sync operations
|
||||
async syncFromStrapi(
|
||||
strapiId: string,
|
||||
data: Partial<CreateLiveBlogDto>,
|
||||
): Promise<LiveBlog> {
|
||||
// Use upsert to handle race conditions and ensure uniqueness
|
||||
try {
|
||||
// First try to find existing live blog by strapiId
|
||||
const liveBlog = await this.liveBlogRepository.findOne({
|
||||
where: { strapiId },
|
||||
});
|
||||
|
||||
if (liveBlog) {
|
||||
// Update existing live blog
|
||||
const currentStatus = liveBlog.status;
|
||||
Object.assign(liveBlog, data);
|
||||
|
||||
// Preserve archived status if live blog is already archived in our system
|
||||
// unless Strapi explicitly sends a different status
|
||||
if (
|
||||
currentStatus === LiveBlogStatus.ARCHIVED &&
|
||||
data.status !== LiveBlogStatus.ARCHIVED
|
||||
) {
|
||||
liveBlog.status = LiveBlogStatus.ARCHIVED;
|
||||
}
|
||||
|
||||
return await this.liveBlogRepository.save(liveBlog);
|
||||
} else {
|
||||
// Create new live blog
|
||||
const newLiveBlog = this.liveBlogRepository.create({
|
||||
strapiId,
|
||||
...data,
|
||||
status: data.status || LiveBlogStatus.DRAFT,
|
||||
});
|
||||
|
||||
return await this.liveBlogRepository.save(newLiveBlog);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// If we get a unique constraint violation, try to find and update again
|
||||
// This handles race conditions where two syncs happen simultaneously
|
||||
const dbError = error as { code?: string; constraint?: string };
|
||||
if (
|
||||
dbError.code === '23505' &&
|
||||
dbError.constraint?.includes('strapiId')
|
||||
) {
|
||||
this.logger.warn(
|
||||
`Race condition detected for strapiId ${strapiId}, retrying...`,
|
||||
);
|
||||
|
||||
// Wait a bit and retry
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const existingLiveBlog = await this.liveBlogRepository.findOne({
|
||||
where: { strapiId },
|
||||
});
|
||||
|
||||
if (existingLiveBlog) {
|
||||
const currentStatus = existingLiveBlog.status;
|
||||
Object.assign(existingLiveBlog, data);
|
||||
|
||||
if (
|
||||
currentStatus === LiveBlogStatus.ARCHIVED &&
|
||||
data.status !== LiveBlogStatus.ARCHIVED
|
||||
) {
|
||||
existingLiveBlog.status = LiveBlogStatus.ARCHIVED;
|
||||
}
|
||||
|
||||
return await this.liveBlogRepository.save(existingLiveBlog);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-throw other errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async removeByStrapiId(strapiId: string): Promise<void> {
|
||||
const liveBlog = await this.liveBlogRepository.findOne({
|
||||
where: { strapiId },
|
||||
});
|
||||
|
||||
if (!liveBlog) {
|
||||
this.logger.warn(`LiveBlog with strapiId ${strapiId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.liveBlogRepository.remove(liveBlog);
|
||||
this.logger.log(
|
||||
`Successfully deleted live blog with strapiId: ${strapiId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
async getLiveBlogsWithRecentUpdates(hours = 24): Promise<LiveBlog[]> {
|
||||
const since = new Date();
|
||||
since.setHours(since.getHours() - hours);
|
||||
|
||||
return await this.liveBlogRepository
|
||||
.createQueryBuilder('liveBlog')
|
||||
.leftJoinAndSelect('liveBlog.author', 'author')
|
||||
.leftJoinAndSelect('liveBlog.updates', 'updates')
|
||||
.where('liveBlog.status = :status', { status: LiveBlogStatus.LIVE })
|
||||
.andWhere('updates.createdAt > :since', { since })
|
||||
.orderBy('updates.createdAt', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async findPinned(): Promise<LiveBlog[]> {
|
||||
return this.liveBlogRepository.find({
|
||||
where: {
|
||||
isPinned: true,
|
||||
status: In([LiveBlogStatus.LIVE, LiveBlogStatus.ENDED]), // Show both live and ended pinned blogs
|
||||
},
|
||||
relations: ['author', 'category', 'updates'],
|
||||
order: { updatedAt: 'DESC' },
|
||||
take: 5,
|
||||
});
|
||||
}
|
||||
|
||||
async findActive(): Promise<LiveBlog[]> {
|
||||
return this.liveBlogRepository.find({
|
||||
where: { status: LiveBlogStatus.LIVE },
|
||||
relations: ['author', 'category'],
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 20,
|
||||
});
|
||||
}
|
||||
}
|
||||
29
backend/src/modules/push/push-subscription.entity.ts
Normal file
29
backend/src/modules/push/push-subscription.entity.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('push_subscriptions')
|
||||
export class PushSubscriptionEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'text', unique: true })
|
||||
endpoint: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
p256dh: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
auth: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
userId: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
63
backend/src/modules/push/push.controller.ts
Normal file
63
backend/src/modules/push/push.controller.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { PushService } from './push.service';
|
||||
import { SubscribeDto, UnsubscribeDto, SendNotificationDto } from './push.dto';
|
||||
import { Public } from '../auth/public.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { UserRole } from '../entities';
|
||||
|
||||
@Controller('push')
|
||||
@UseGuards(RolesGuard)
|
||||
export class PushController {
|
||||
constructor(private readonly pushService: PushService) {}
|
||||
|
||||
@Public()
|
||||
@Get('vapid-public-key')
|
||||
getVapidPublicKey(): { publicKey: string | null } {
|
||||
return { publicKey: this.pushService.getPublicKey() };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('subscribe')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async subscribe(@Body() dto: SubscribeDto): Promise<{ success: boolean }> {
|
||||
await this.pushService.subscribe(dto);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Delete('unsubscribe')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async unsubscribe(@Body() dto: UnsubscribeDto): Promise<void> {
|
||||
await this.pushService.unsubscribe(dto);
|
||||
}
|
||||
|
||||
@Roles(UserRole.ADMIN)
|
||||
@Get('stats')
|
||||
getStats(): Promise<{ totalSubscribers: number }> {
|
||||
return this.pushService.getStats();
|
||||
}
|
||||
|
||||
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||
@Post('send')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async sendNotification(
|
||||
@Body() dto: SendNotificationDto,
|
||||
): Promise<{ sent: number; failed: number }> {
|
||||
return this.pushService.sendToAll({
|
||||
title: dto.title,
|
||||
body: dto.body,
|
||||
url: dto.url,
|
||||
tag: 'admin-notification',
|
||||
});
|
||||
}
|
||||
}
|
||||
39
backend/src/modules/push/push.dto.ts
Normal file
39
backend/src/modules/push/push.dto.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
|
||||
|
||||
export class SubscribeDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
endpoint: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
p256dh: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
auth: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export class UnsubscribeDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
export class SendNotificationDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
body: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
url?: string;
|
||||
}
|
||||
13
backend/src/modules/push/push.module.ts
Normal file
13
backend/src/modules/push/push.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { PushController } from './push.controller';
|
||||
import { PushService } from './push.service';
|
||||
import { PushSubscriptionEntity } from './push-subscription.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([PushSubscriptionEntity])],
|
||||
controllers: [PushController],
|
||||
providers: [PushService],
|
||||
exports: [PushService],
|
||||
})
|
||||
export class PushModule {}
|
||||
178
backend/src/modules/push/push.service.ts
Normal file
178
backend/src/modules/push/push.service.ts
Normal file
@ -0,0 +1,178 @@
|
||||
import * as webpush from 'web-push';
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PushSubscriptionEntity } from './push-subscription.entity';
|
||||
import { SubscribeDto, UnsubscribeDto } from './push.dto';
|
||||
|
||||
export interface PushPayload {
|
||||
title: string;
|
||||
body: string;
|
||||
icon?: string;
|
||||
badge?: string;
|
||||
url?: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PushService implements OnModuleInit {
|
||||
private readonly logger = new Logger(PushService.name);
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
@InjectRepository(PushSubscriptionEntity)
|
||||
private subscriptionRepo: Repository<PushSubscriptionEntity>,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
const publicKey = this.configService.get<string>('VAPID_PUBLIC_KEY');
|
||||
const privateKey = this.configService.get<string>('VAPID_PRIVATE_KEY');
|
||||
const subject = this.configService.get<string>(
|
||||
'VAPID_SUBJECT',
|
||||
'mailto:contact@placebo.mk',
|
||||
);
|
||||
|
||||
if (!publicKey || !privateKey) {
|
||||
this.logger.warn(
|
||||
'VAPID keys not configured. Push notifications will not work. Run: npm run generate-vapid',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
webpush.setVapidDetails(subject, publicKey, privateKey);
|
||||
this.logger.log('VAPID keys configured successfully');
|
||||
}
|
||||
|
||||
getPublicKey(): string | null {
|
||||
return this.configService.get<string>('VAPID_PUBLIC_KEY') ?? null;
|
||||
}
|
||||
|
||||
async subscribe(dto: SubscribeDto): Promise<PushSubscriptionEntity> {
|
||||
const existing = await this.subscriptionRepo.findOne({
|
||||
where: { endpoint: dto.endpoint },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (dto.userId && existing.userId !== dto.userId) {
|
||||
existing.userId = dto.userId;
|
||||
return this.subscriptionRepo.save(existing);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
const subscription = this.subscriptionRepo.create({
|
||||
endpoint: dto.endpoint,
|
||||
p256dh: dto.p256dh,
|
||||
auth: dto.auth,
|
||||
userId: dto.userId ?? null,
|
||||
});
|
||||
|
||||
return this.subscriptionRepo.save(subscription);
|
||||
}
|
||||
|
||||
async unsubscribe(dto: UnsubscribeDto): Promise<void> {
|
||||
await this.subscriptionRepo.delete({ endpoint: dto.endpoint });
|
||||
}
|
||||
|
||||
async sendToAll(
|
||||
payload: PushPayload,
|
||||
): Promise<{ sent: number; failed: number }> {
|
||||
const subscriptions = await this.subscriptionRepo.find();
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
return { sent: 0, failed: 0 };
|
||||
}
|
||||
|
||||
const notification = JSON.stringify({
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
icon: payload.icon ?? '/icons/icon-192.png',
|
||||
badge: payload.badge ?? '/icons/badge-72.png',
|
||||
url: payload.url ?? '/',
|
||||
tag: payload.tag,
|
||||
});
|
||||
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
subscriptions.map(async (sub) => {
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{
|
||||
endpoint: sub.endpoint,
|
||||
keys: {
|
||||
p256dh: sub.p256dh,
|
||||
auth: sub.auth,
|
||||
},
|
||||
},
|
||||
notification,
|
||||
{
|
||||
TTL: 86400,
|
||||
},
|
||||
);
|
||||
return { success: true, id: sub.id };
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
this.logger.warn(`Push failed for ${sub.id}: ${error.message}`);
|
||||
}
|
||||
if (
|
||||
error instanceof webpush.WebPushError &&
|
||||
(error.statusCode === 410 || error.statusCode === 404)
|
||||
) {
|
||||
await this.subscriptionRepo.delete({ id: sub.id });
|
||||
this.logger.log(`Removed invalid subscription: ${sub.id}`);
|
||||
}
|
||||
return { success: false, id: sub.id };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value.success) {
|
||||
sent++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Push sent: ${sent}, failed: ${failed}`);
|
||||
return { sent, failed };
|
||||
}
|
||||
|
||||
async notifyNewArticle(
|
||||
articleTitle: string,
|
||||
articleSlug: string,
|
||||
): Promise<void> {
|
||||
await this.sendToAll({
|
||||
title: 'Нови вести! 📰',
|
||||
body: articleTitle,
|
||||
url: `/article/${articleSlug}`,
|
||||
tag: 'new-article',
|
||||
});
|
||||
}
|
||||
|
||||
async notifyLiveBlogUpdate(
|
||||
blogTitle: string,
|
||||
blogSlug: string,
|
||||
updatePreview: string,
|
||||
): Promise<void> {
|
||||
const body =
|
||||
updatePreview.length > 100
|
||||
? updatePreview.substring(0, 97) + '...'
|
||||
: updatePreview;
|
||||
|
||||
await this.sendToAll({
|
||||
title: `${blogTitle} 📡`,
|
||||
body,
|
||||
url: `/live-blog/${blogSlug}`,
|
||||
tag: `live-blog-${blogSlug}`,
|
||||
});
|
||||
}
|
||||
|
||||
async getStats(): Promise<{ totalSubscribers: number }> {
|
||||
const totalSubscribers = await this.subscriptionRepo.count();
|
||||
return { totalSubscribers };
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,14 @@
|
||||
import { Controller, Post, Body } from '@nestjs/common';
|
||||
import { Controller, Post, Get, Body, Logger } from '@nestjs/common';
|
||||
import { StrapiService } from './strapi.service';
|
||||
import { Public } from './auth/public.decorator';
|
||||
|
||||
interface WebhookBody {
|
||||
event: 'entry.create' | 'entry.update' | 'entry.delete';
|
||||
event:
|
||||
| 'entry.create'
|
||||
| 'entry.update'
|
||||
| 'entry.delete'
|
||||
| 'entry.publish'
|
||||
| 'entry.unpublish';
|
||||
model: string;
|
||||
entry: {
|
||||
documentId: string;
|
||||
@ -11,23 +17,136 @@ interface WebhookBody {
|
||||
|
||||
@Controller('webhooks/strapi')
|
||||
export class StrapiController {
|
||||
private readonly logger = new Logger(StrapiController.name);
|
||||
|
||||
constructor(private readonly strapiService: StrapiService) {}
|
||||
|
||||
@Post('article')
|
||||
@Public()
|
||||
async handleArticleWebhook(@Body() body: WebhookBody) {
|
||||
this.logger.log(`Received article webhook: ${JSON.stringify(body)}`);
|
||||
const { event, model, entry } = body;
|
||||
|
||||
if (model !== 'article') {
|
||||
this.logger.warn(`Ignored: not an article, model: ${model}`);
|
||||
return { message: 'Ignored: not an article' };
|
||||
}
|
||||
|
||||
await this.strapiService.handleWebhook(event, entry);
|
||||
await this.strapiService.handleWebhook(event, {
|
||||
documentId: entry.documentId,
|
||||
model,
|
||||
});
|
||||
return { message: 'Webhook processed successfully' };
|
||||
}
|
||||
|
||||
@Post('live-blog')
|
||||
@Public()
|
||||
async handleLiveBlogWebhook(@Body() body: WebhookBody) {
|
||||
this.logger.log(`Received live-blog webhook: ${JSON.stringify(body)}`);
|
||||
const { event, model, entry } = body;
|
||||
|
||||
if (model !== 'live-blog') {
|
||||
this.logger.warn(`Ignored: not a live blog, model: ${model}`);
|
||||
return { message: 'Ignored: not a live blog' };
|
||||
}
|
||||
|
||||
await this.strapiService.handleWebhook(event, {
|
||||
documentId: entry.documentId,
|
||||
model,
|
||||
});
|
||||
return { message: 'Live blog webhook processed successfully' };
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Public()
|
||||
async handleGenericWebhook(@Body() body: unknown) {
|
||||
this.logger.log(`Received generic webhook: ${JSON.stringify(body)}`);
|
||||
|
||||
// Type guard to check if body is an object
|
||||
if (typeof body !== 'object' || body === null) {
|
||||
this.logger.warn(`Invalid webhook payload: ${JSON.stringify(body)}`);
|
||||
return { message: 'Invalid payload' };
|
||||
}
|
||||
|
||||
const bodyObj = body as Record<string, unknown>;
|
||||
|
||||
// Try to extract event and model from different possible payload structures
|
||||
const event = (bodyObj.event || bodyObj.type) as string;
|
||||
const model = (bodyObj.model || bodyObj.contentType) as string;
|
||||
const entry = bodyObj.entry || bodyObj.data || bodyObj;
|
||||
|
||||
if (!event || !model) {
|
||||
this.logger.warn(
|
||||
`Cannot process webhook: missing event or model. Payload: ${JSON.stringify(body)}`,
|
||||
);
|
||||
return { message: 'Cannot process: missing event or model' };
|
||||
}
|
||||
|
||||
const entryObj = entry as Record<string, unknown>;
|
||||
const documentId = (entryObj.documentId ||
|
||||
entryObj.id ||
|
||||
(entryObj.document as Record<string, unknown>)?.id) as string;
|
||||
|
||||
if (!documentId) {
|
||||
this.logger.warn(
|
||||
`Cannot process webhook: missing documentId. Payload: ${JSON.stringify(body)}`,
|
||||
);
|
||||
return { message: 'Cannot process: missing documentId' };
|
||||
}
|
||||
|
||||
// Validate event type
|
||||
const validEvents = [
|
||||
'entry.create',
|
||||
'entry.update',
|
||||
'entry.delete',
|
||||
'entry.publish',
|
||||
'entry.unpublish',
|
||||
];
|
||||
if (!validEvents.includes(event)) {
|
||||
this.logger.warn(`Invalid event type: ${event}`);
|
||||
return { message: 'Invalid event type' };
|
||||
}
|
||||
|
||||
await this.strapiService.handleWebhook(
|
||||
event as
|
||||
| 'entry.create'
|
||||
| 'entry.update'
|
||||
| 'entry.delete'
|
||||
| 'entry.publish'
|
||||
| 'entry.unpublish',
|
||||
{ documentId, model },
|
||||
);
|
||||
return { message: 'Webhook processed successfully' };
|
||||
}
|
||||
|
||||
@Post('sync/all')
|
||||
@Public()
|
||||
async syncAllArticles() {
|
||||
await this.strapiService.syncArticles();
|
||||
return { message: 'Sync completed' };
|
||||
return { message: 'Articles sync completed' };
|
||||
}
|
||||
|
||||
@Get('sync/all')
|
||||
@Public()
|
||||
async syncAllArticlesGet() {
|
||||
await this.strapiService.syncArticles();
|
||||
return { message: 'Articles sync completed' };
|
||||
}
|
||||
|
||||
@Post('sync/live-blogs')
|
||||
@Public()
|
||||
async syncAllLiveBlogs() {
|
||||
await this.strapiService.syncLiveBlogs();
|
||||
return { message: 'Live blogs sync completed' };
|
||||
}
|
||||
|
||||
@Post('sync/everything')
|
||||
@Public()
|
||||
async syncEverything() {
|
||||
await Promise.all([
|
||||
this.strapiService.syncArticles(),
|
||||
this.strapiService.syncLiveBlogs(),
|
||||
]);
|
||||
return { message: 'Full sync completed' };
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { StrapiService } from './strapi.service';
|
||||
import { StrapiController } from './strapi.controller';
|
||||
import { ArticlesModule } from './articles.module';
|
||||
import { LiveBlogModule } from './live-blog.module';
|
||||
import { Category } from './entities';
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule, ArticlesModule],
|
||||
imports: [
|
||||
HttpModule,
|
||||
TypeOrmModule.forFeature([Category]),
|
||||
forwardRef(() => ArticlesModule),
|
||||
forwardRef(() => LiveBlogModule),
|
||||
],
|
||||
controllers: [StrapiController],
|
||||
providers: [StrapiService],
|
||||
exports: [StrapiService],
|
||||
|
||||
@ -1,10 +1,35 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { ArticlesService } from './articles.service';
|
||||
import { CreateArticleDto } from './articles.dto';
|
||||
import { ArticleStatus } from './entities';
|
||||
import { LiveBlogService } from './live-blog.service';
|
||||
import { CreateArticleDto, CreateLiveBlogDto } from './articles.dto';
|
||||
import {
|
||||
ArticleStatus,
|
||||
LiveBlogStatus,
|
||||
ImagePosition,
|
||||
ImageSize,
|
||||
VideoPosition,
|
||||
Category,
|
||||
} from './entities';
|
||||
|
||||
interface StrapiImage {
|
||||
url: string;
|
||||
alternativeText?: string;
|
||||
caption?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
formats?: Record<string, StrapiImageFormat>;
|
||||
}
|
||||
|
||||
interface StrapiImageFormat {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface StrapiArticle {
|
||||
id: number;
|
||||
@ -16,6 +41,34 @@ interface StrapiArticle {
|
||||
publishedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
img?: StrapiImage;
|
||||
media?: StrapiImage[];
|
||||
imagePosition?: string;
|
||||
imageSize?: string;
|
||||
videoUrl?: string;
|
||||
videoPosition?: string;
|
||||
videoCaption?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
interface StrapiLiveBlog {
|
||||
id: number;
|
||||
documentId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
slug: string;
|
||||
status: 'draft' | 'live' | 'ended' | 'archived';
|
||||
publishedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
img?: StrapiImage;
|
||||
media?: StrapiImage[];
|
||||
imagePosition?: string;
|
||||
imageSize?: string;
|
||||
videoUrl?: string;
|
||||
videoPosition?: string;
|
||||
videoCaption?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
interface StrapiResponse<T> {
|
||||
@ -34,27 +87,136 @@ interface StrapiResponse<T> {
|
||||
export class StrapiService {
|
||||
private readonly logger = new Logger(StrapiService.name);
|
||||
private readonly strapiUrl: string;
|
||||
private readonly strapiPublicUrl: string;
|
||||
private readonly strapiApiToken: string;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly httpService: HttpService,
|
||||
@Inject(forwardRef(() => ArticlesService))
|
||||
private readonly articlesService: ArticlesService,
|
||||
@Inject(forwardRef(() => LiveBlogService))
|
||||
private readonly liveBlogService: LiveBlogService,
|
||||
@InjectRepository(Category)
|
||||
private readonly categoryRepository: Repository<Category>,
|
||||
) {
|
||||
this.strapiUrl =
|
||||
this.configService.get<string>('STRAPI_URL') || 'http://localhost:1337';
|
||||
this.strapiPublicUrl =
|
||||
this.configService.get<string>('STRAPI_PUBLIC_URL') ||
|
||||
this.strapiUrl.replace('cms:', 'localhost:');
|
||||
this.strapiApiToken =
|
||||
this.configService.get<string>('STRAPI_API_TOKEN') || '';
|
||||
}
|
||||
|
||||
private getHeaders() {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (this.strapiApiToken) {
|
||||
headers['Authorization'] = `Bearer ${this.strapiApiToken}`;
|
||||
// Use public API access - no authentication needed
|
||||
// Strapi Public role has been configured to allow article access
|
||||
return {};
|
||||
}
|
||||
|
||||
private generateSlug(title: string): string {
|
||||
// Generate slug from title if missing
|
||||
return title
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '') // Remove special characters
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
|
||||
.substring(0, 100); // Limit length
|
||||
}
|
||||
|
||||
private async findOrCreateCategory(
|
||||
categorySlug: string,
|
||||
): Promise<Category | null> {
|
||||
if (!categorySlug) {
|
||||
return null;
|
||||
}
|
||||
return headers;
|
||||
|
||||
// Map CMS category slugs to Macedonian display names
|
||||
const categoryMap: Record<string, { name: string; description: string }> = {
|
||||
general: { name: 'Општо', description: 'Општи вести и теми' },
|
||||
sport: { name: 'Спорт', description: 'Спортски вести и анализи' },
|
||||
art: { name: 'Уметност', description: 'Уметност, култура и забава' },
|
||||
science: { name: 'Наука', description: 'Научни откритија и технологија' },
|
||||
};
|
||||
|
||||
const categoryInfo = categoryMap[categorySlug];
|
||||
if (!categoryInfo) {
|
||||
this.logger.warn(`Unknown category slug: ${categorySlug}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to find existing category by slug
|
||||
let category = await this.categoryRepository.findOne({
|
||||
where: { slug: categorySlug },
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
// Create new category
|
||||
category = this.categoryRepository.create({
|
||||
name: categoryInfo.name,
|
||||
slug: categorySlug,
|
||||
description: categoryInfo.description,
|
||||
order: 0,
|
||||
});
|
||||
category = await this.categoryRepository.save(category);
|
||||
this.logger.log(
|
||||
`Created new category: ${categorySlug} (${categoryInfo.name})`,
|
||||
);
|
||||
}
|
||||
|
||||
return category;
|
||||
}
|
||||
|
||||
private extractImageUrl(strapiArticle: StrapiArticle): string | undefined {
|
||||
// Try to get image from img field first (single image)
|
||||
let imageUrl: string | undefined;
|
||||
|
||||
if (strapiArticle.img?.url) {
|
||||
imageUrl = strapiArticle.img.url;
|
||||
} else if (strapiArticle.media?.[0]?.url) {
|
||||
// Try to get first image from media field (multiple images)
|
||||
imageUrl = strapiArticle.media[0].url;
|
||||
}
|
||||
|
||||
if (!imageUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If URL is relative, prepend Strapi base URL
|
||||
if (imageUrl.startsWith('/')) {
|
||||
// Use public URL for frontend access
|
||||
return `${this.strapiPublicUrl}${imageUrl}`;
|
||||
}
|
||||
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
private extractLiveBlogImageUrl(
|
||||
strapiLiveBlog: StrapiLiveBlog,
|
||||
): string | undefined {
|
||||
// Try to get image from img field first (single image)
|
||||
let imageUrl: string | undefined;
|
||||
|
||||
if (strapiLiveBlog.img?.url) {
|
||||
imageUrl = strapiLiveBlog.img.url;
|
||||
} else if (strapiLiveBlog.media?.[0]?.url) {
|
||||
// Try to get first image from media field (multiple images)
|
||||
imageUrl = strapiLiveBlog.media[0].url;
|
||||
}
|
||||
|
||||
if (!imageUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If URL is relative, prepend Strapi base URL
|
||||
if (imageUrl.startsWith('/')) {
|
||||
// Use public URL for frontend access
|
||||
return `${this.strapiPublicUrl}${imageUrl}`;
|
||||
}
|
||||
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
async syncArticles(): Promise<void> {
|
||||
@ -63,7 +225,7 @@ export class StrapiService {
|
||||
|
||||
const response = await lastValueFrom(
|
||||
this.httpService.get<StrapiResponse<StrapiArticle[]>>(
|
||||
`${this.strapiUrl}/api/articles`,
|
||||
`${this.strapiUrl}/api/articles?populate=*`,
|
||||
{
|
||||
headers: this.getHeaders(),
|
||||
},
|
||||
@ -74,15 +236,41 @@ export class StrapiService {
|
||||
let syncedCount = 0;
|
||||
|
||||
for (const strapiArticle of strapiArticles) {
|
||||
const imageUrl = this.extractImageUrl(strapiArticle);
|
||||
|
||||
// Find or create category
|
||||
let categoryId: string | undefined;
|
||||
if (strapiArticle.category) {
|
||||
const category = await this.findOrCreateCategory(
|
||||
strapiArticle.category,
|
||||
);
|
||||
if (category) {
|
||||
categoryId = category.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate slug if missing or null
|
||||
const slug =
|
||||
strapiArticle.slug || this.generateSlug(strapiArticle.title);
|
||||
|
||||
const articleData: Partial<CreateArticleDto> = {
|
||||
title: strapiArticle.title,
|
||||
excerpt: strapiArticle.description,
|
||||
excerpt: strapiArticle.description || '',
|
||||
content: strapiArticle.content,
|
||||
slug: strapiArticle.slug,
|
||||
slug,
|
||||
status: strapiArticle.publishedAt
|
||||
? ArticleStatus.PUBLISHED
|
||||
: ArticleStatus.DRAFT,
|
||||
tags: [],
|
||||
featuredImage: imageUrl,
|
||||
imagePosition: (strapiArticle.imagePosition ||
|
||||
'top') as ImagePosition,
|
||||
imageSize: (strapiArticle.imageSize || 'medium') as ImageSize,
|
||||
videoUrl: strapiArticle.videoUrl || '',
|
||||
videoPosition: (strapiArticle.videoPosition ||
|
||||
'inline') as VideoPosition,
|
||||
videoCaption: strapiArticle.videoCaption || '',
|
||||
categoryId,
|
||||
};
|
||||
|
||||
await this.articlesService.syncFromStrapi(
|
||||
@ -101,13 +289,23 @@ export class StrapiService {
|
||||
}
|
||||
}
|
||||
|
||||
async syncSingleArticle(strapiId: string): Promise<void> {
|
||||
async syncSingleArticle(
|
||||
strapiId: string,
|
||||
event?:
|
||||
| 'entry.create'
|
||||
| 'entry.update'
|
||||
| 'entry.delete'
|
||||
| 'entry.publish'
|
||||
| 'entry.unpublish',
|
||||
): Promise<void> {
|
||||
try {
|
||||
this.logger.log(`Syncing single article from Strapi: ${strapiId}`);
|
||||
this.logger.log(
|
||||
`Syncing single article from Strapi: ${strapiId}, event: ${event}`,
|
||||
);
|
||||
|
||||
const response = await lastValueFrom(
|
||||
this.httpService.get<StrapiResponse<StrapiArticle>>(
|
||||
`${this.strapiUrl}/api/articles/${strapiId}`,
|
||||
`${this.strapiUrl}/api/articles/${strapiId}?populate=*`,
|
||||
{
|
||||
headers: this.getHeaders(),
|
||||
},
|
||||
@ -115,42 +313,469 @@ export class StrapiService {
|
||||
);
|
||||
|
||||
const strapiArticle = response.data.data;
|
||||
|
||||
// Determine status based on publishedAt and event type
|
||||
let status: ArticleStatus;
|
||||
if (event === 'entry.unpublish') {
|
||||
// If it's an unpublish event, always mark as draft
|
||||
status = ArticleStatus.DRAFT;
|
||||
} else if (strapiArticle.publishedAt) {
|
||||
// If publishedAt exists, it's published
|
||||
status = ArticleStatus.PUBLISHED;
|
||||
} else {
|
||||
// Otherwise it's a draft
|
||||
status = ArticleStatus.DRAFT;
|
||||
}
|
||||
|
||||
const imageUrl = this.extractImageUrl(strapiArticle);
|
||||
|
||||
// Find or create category
|
||||
let categoryId: string | undefined;
|
||||
if (strapiArticle.category) {
|
||||
const category = await this.findOrCreateCategory(
|
||||
strapiArticle.category,
|
||||
);
|
||||
if (category) {
|
||||
categoryId = category.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate slug if missing or null
|
||||
const slug = strapiArticle.slug || this.generateSlug(strapiArticle.title);
|
||||
|
||||
const articleData: Partial<CreateArticleDto> = {
|
||||
title: strapiArticle.title,
|
||||
excerpt: strapiArticle.description,
|
||||
excerpt: strapiArticle.description || '',
|
||||
content: strapiArticle.content,
|
||||
slug: strapiArticle.slug,
|
||||
status: strapiArticle.publishedAt
|
||||
? ArticleStatus.PUBLISHED
|
||||
: ArticleStatus.DRAFT,
|
||||
slug,
|
||||
status,
|
||||
tags: [],
|
||||
featuredImage: imageUrl,
|
||||
imagePosition: (strapiArticle.imagePosition || 'top') as ImagePosition,
|
||||
imageSize: (strapiArticle.imageSize || 'medium') as ImageSize,
|
||||
videoUrl: strapiArticle.videoUrl || '',
|
||||
videoPosition: (strapiArticle.videoPosition ||
|
||||
'inline') as VideoPosition,
|
||||
videoCaption: strapiArticle.videoCaption || '',
|
||||
categoryId,
|
||||
};
|
||||
|
||||
await this.articlesService.syncFromStrapi(
|
||||
strapiArticle.documentId,
|
||||
articleData,
|
||||
);
|
||||
this.logger.log(`Successfully synced article: ${strapiArticle.title}`);
|
||||
this.logger.log(
|
||||
`Successfully synced article: ${strapiArticle.title} with status: ${status}`,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
// If we get a 404 and it's an unpublish event, we can still mark it as draft
|
||||
const axiosError = error as { response?: { status: number } };
|
||||
if (event === 'entry.unpublish' && axiosError.response?.status === 404) {
|
||||
this.logger.log(
|
||||
`Article ${strapiId} not found in Strapi, marking as draft based on unpublish event`,
|
||||
);
|
||||
|
||||
// Try to update the article status to draft directly
|
||||
try {
|
||||
const articleData: Partial<CreateArticleDto> = {
|
||||
status: ArticleStatus.DRAFT,
|
||||
};
|
||||
|
||||
await this.articlesService.syncFromStrapi(strapiId, articleData);
|
||||
this.logger.log(
|
||||
`Marked article ${strapiId} as draft based on unpublish event`,
|
||||
);
|
||||
} catch (syncError) {
|
||||
this.logger.error(
|
||||
`Failed to mark article ${strapiId} as draft:`,
|
||||
syncError,
|
||||
);
|
||||
throw syncError;
|
||||
}
|
||||
} else {
|
||||
this.logger.error(
|
||||
`Failed to sync article ${strapiId} from Strapi`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async syncLiveBlogs(): Promise<void> {
|
||||
try {
|
||||
this.logger.log('Starting live blogs sync from Strapi...');
|
||||
|
||||
const response = await lastValueFrom(
|
||||
this.httpService.get<StrapiResponse<StrapiLiveBlog[]>>(
|
||||
`${this.strapiUrl}/api/live-blogs?populate=*`,
|
||||
{
|
||||
headers: this.getHeaders(),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const strapiLiveBlogs = response.data.data;
|
||||
let syncedCount = 0;
|
||||
|
||||
for (const strapiLiveBlog of strapiLiveBlogs) {
|
||||
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
|
||||
|
||||
// Find or create category
|
||||
let categoryId: string | undefined;
|
||||
if (strapiLiveBlog.category) {
|
||||
const category = await this.findOrCreateCategory(
|
||||
strapiLiveBlog.category,
|
||||
);
|
||||
if (category) {
|
||||
categoryId = category.id;
|
||||
}
|
||||
}
|
||||
|
||||
const liveBlogData: Partial<CreateLiveBlogDto> = {
|
||||
title: strapiLiveBlog.title,
|
||||
description: strapiLiveBlog.description,
|
||||
slug: strapiLiveBlog.slug,
|
||||
status: this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status),
|
||||
featuredImage: imageUrl,
|
||||
imagePosition: (strapiLiveBlog.imagePosition ||
|
||||
'top') as ImagePosition,
|
||||
imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize,
|
||||
videoUrl: strapiLiveBlog.videoUrl || '',
|
||||
videoPosition: (strapiLiveBlog.videoPosition ||
|
||||
'inline') as VideoPosition,
|
||||
videoCaption: strapiLiveBlog.videoCaption || '',
|
||||
categoryId,
|
||||
};
|
||||
|
||||
await this.liveBlogService.syncFromStrapi(
|
||||
strapiLiveBlog.documentId,
|
||||
liveBlogData,
|
||||
);
|
||||
syncedCount++;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Successfully synced ${syncedCount} live blogs from Strapi`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to sync live blogs from Strapi', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async syncSingleLiveBlog(
|
||||
strapiId: string,
|
||||
event?:
|
||||
| 'entry.create'
|
||||
| 'entry.update'
|
||||
| 'entry.delete'
|
||||
| 'entry.publish'
|
||||
| 'entry.unpublish',
|
||||
): Promise<void> {
|
||||
try {
|
||||
this.logger.log(
|
||||
`Syncing single live blog from Strapi: ${strapiId}, event: ${event}`,
|
||||
);
|
||||
|
||||
const response = await lastValueFrom(
|
||||
this.httpService.get<StrapiResponse<StrapiLiveBlog>>(
|
||||
`${this.strapiUrl}/api/live-blogs/${strapiId}?populate=*`,
|
||||
{
|
||||
headers: this.getHeaders(),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const strapiLiveBlog = response.data.data;
|
||||
|
||||
// For live blogs, we use the status from Strapi directly
|
||||
// but we might want to handle unpublish events differently
|
||||
let status = this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status);
|
||||
|
||||
// If it's an unpublish event, set to draft
|
||||
if (event === 'entry.unpublish') {
|
||||
status = LiveBlogStatus.DRAFT;
|
||||
}
|
||||
|
||||
const imageUrl = this.extractLiveBlogImageUrl(strapiLiveBlog);
|
||||
|
||||
// Find or create category
|
||||
let categoryId: string | undefined;
|
||||
if (strapiLiveBlog.category) {
|
||||
const category = await this.findOrCreateCategory(
|
||||
strapiLiveBlog.category,
|
||||
);
|
||||
if (category) {
|
||||
categoryId = category.id;
|
||||
}
|
||||
}
|
||||
|
||||
const liveBlogData: Partial<CreateLiveBlogDto> = {
|
||||
title: strapiLiveBlog.title,
|
||||
description: strapiLiveBlog.description,
|
||||
slug: strapiLiveBlog.slug,
|
||||
status,
|
||||
featuredImage: imageUrl,
|
||||
imagePosition: (strapiLiveBlog.imagePosition || 'top') as ImagePosition,
|
||||
imageSize: (strapiLiveBlog.imageSize || 'medium') as ImageSize,
|
||||
videoUrl: strapiLiveBlog.videoUrl || '',
|
||||
videoPosition: (strapiLiveBlog.videoPosition ||
|
||||
'inline') as VideoPosition,
|
||||
videoCaption: strapiLiveBlog.videoCaption || '',
|
||||
categoryId,
|
||||
};
|
||||
|
||||
await this.liveBlogService.syncFromStrapi(
|
||||
strapiLiveBlog.documentId,
|
||||
liveBlogData,
|
||||
);
|
||||
this.logger.log(
|
||||
`Successfully synced live blog: ${strapiLiveBlog.title} with status: ${status}`,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
// If we get a 404 and it's an unpublish event, we can still mark it as draft
|
||||
const axiosError = error as { response?: { status: number } };
|
||||
if (event === 'entry.unpublish' && axiosError.response?.status === 404) {
|
||||
this.logger.log(
|
||||
`Live blog ${strapiId} not found in Strapi, marking as draft based on unpublish event`,
|
||||
);
|
||||
|
||||
// Try to update the live blog status to draft directly
|
||||
try {
|
||||
const liveBlogData: Partial<CreateLiveBlogDto> = {
|
||||
status: LiveBlogStatus.DRAFT,
|
||||
};
|
||||
|
||||
await this.liveBlogService.syncFromStrapi(strapiId, liveBlogData);
|
||||
this.logger.log(
|
||||
`Marked live blog ${strapiId} as draft based on unpublish event`,
|
||||
);
|
||||
} catch (syncError) {
|
||||
this.logger.error(
|
||||
`Failed to mark live blog ${strapiId} as draft:`,
|
||||
syncError,
|
||||
);
|
||||
throw syncError;
|
||||
}
|
||||
} else {
|
||||
this.logger.error(
|
||||
`Failed to sync live blog ${strapiId} from Strapi`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mapStrapiStatusToLiveBlogStatus(status: string): LiveBlogStatus {
|
||||
switch (status) {
|
||||
case 'live':
|
||||
return LiveBlogStatus.LIVE;
|
||||
case 'draft':
|
||||
return LiveBlogStatus.DRAFT;
|
||||
case 'ended':
|
||||
return LiveBlogStatus.ENDED;
|
||||
case 'archived':
|
||||
return LiveBlogStatus.ARCHIVED;
|
||||
default:
|
||||
return LiveBlogStatus.DRAFT;
|
||||
}
|
||||
}
|
||||
|
||||
async handleWebhook(
|
||||
event:
|
||||
| 'entry.create'
|
||||
| 'entry.update'
|
||||
| 'entry.delete'
|
||||
| 'entry.publish'
|
||||
| 'entry.unpublish',
|
||||
data: { documentId: string; model?: string },
|
||||
): Promise<void> {
|
||||
this.logger.log(
|
||||
`Received webhook event: ${event} for model: ${data.model}`,
|
||||
);
|
||||
|
||||
if (event === 'entry.delete') {
|
||||
this.logger.log(
|
||||
`Handling delete for document: ${data.documentId}, model: ${data.model}`,
|
||||
);
|
||||
|
||||
if (data.model === 'article') {
|
||||
await this.articlesService.removeByStrapiId(data.documentId);
|
||||
} else if (data.model === 'live-blog') {
|
||||
await this.liveBlogService.removeByStrapiId(data.documentId);
|
||||
} else {
|
||||
this.logger.warn(`Cannot delete: unknown model type: ${data.model}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Route to appropriate sync method based on model
|
||||
if (data.model === 'article') {
|
||||
await this.syncSingleArticle(data.documentId, event);
|
||||
} else if (data.model === 'live-blog') {
|
||||
await this.syncSingleLiveBlog(data.documentId, event);
|
||||
} else {
|
||||
this.logger.warn(`Unknown model type in webhook: ${data.model}`);
|
||||
}
|
||||
}
|
||||
|
||||
async updateArticleStatusInStrapi(
|
||||
strapiId: string,
|
||||
status: ArticleStatus,
|
||||
): Promise<void> {
|
||||
try {
|
||||
this.logger.log(
|
||||
`Updating article ${strapiId} status in Strapi to: ${status}`,
|
||||
);
|
||||
|
||||
// Map our status to Strapi status
|
||||
let strapiStatus: string;
|
||||
switch (status) {
|
||||
case ArticleStatus.DRAFT:
|
||||
strapiStatus = 'draft';
|
||||
break;
|
||||
case ArticleStatus.PUBLISHED:
|
||||
strapiStatus = 'published';
|
||||
break;
|
||||
case ArticleStatus.ARCHIVED:
|
||||
strapiStatus = 'archived';
|
||||
break;
|
||||
default:
|
||||
strapiStatus = 'draft';
|
||||
}
|
||||
|
||||
// For archived status, we need to unpublish first if it's published
|
||||
if (status === ArticleStatus.ARCHIVED) {
|
||||
await lastValueFrom(
|
||||
this.httpService.patch(
|
||||
`${this.strapiUrl}/api/articles/${strapiId}/unpublish`,
|
||||
{},
|
||||
{ headers: this.getHeaders() },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Update the status
|
||||
await lastValueFrom(
|
||||
this.httpService.patch(
|
||||
`${this.strapiUrl}/api/articles/${strapiId}`,
|
||||
{ data: { status: strapiStatus } },
|
||||
{ headers: this.getHeaders() },
|
||||
),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Successfully updated article ${strapiId} status in Strapi to: ${status}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to sync article ${strapiId} from Strapi`,
|
||||
`Failed to update article ${strapiId} status in Strapi:`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async handleWebhook(
|
||||
event: 'entry.create' | 'entry.update' | 'entry.delete',
|
||||
data: { documentId: string },
|
||||
async updateLiveBlogStatusInStrapi(
|
||||
strapiId: string,
|
||||
status: LiveBlogStatus,
|
||||
): Promise<void> {
|
||||
this.logger.log(`Received webhook event: ${event}`);
|
||||
try {
|
||||
this.logger.log(
|
||||
`Updating live blog ${strapiId} status in Strapi to: ${status}`,
|
||||
);
|
||||
|
||||
if (event === 'entry.delete') {
|
||||
this.logger.log(`Handling delete for document: ${data.documentId}`);
|
||||
return;
|
||||
// Map our status to Strapi status
|
||||
let strapiStatus: string;
|
||||
switch (status) {
|
||||
case LiveBlogStatus.DRAFT:
|
||||
strapiStatus = 'draft';
|
||||
break;
|
||||
case LiveBlogStatus.LIVE:
|
||||
strapiStatus = 'live';
|
||||
break;
|
||||
case LiveBlogStatus.ENDED:
|
||||
strapiStatus = 'ended';
|
||||
break;
|
||||
case LiveBlogStatus.ARCHIVED:
|
||||
strapiStatus = 'archived';
|
||||
break;
|
||||
default:
|
||||
strapiStatus = 'draft';
|
||||
}
|
||||
|
||||
// For archived status, we need to unpublish first if it's published
|
||||
if (status === LiveBlogStatus.ARCHIVED) {
|
||||
await lastValueFrom(
|
||||
this.httpService.patch(
|
||||
`${this.strapiUrl}/api/live-blogs/${strapiId}/unpublish`,
|
||||
{},
|
||||
{ headers: this.getHeaders() },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Update the status
|
||||
await lastValueFrom(
|
||||
this.httpService.patch(
|
||||
`${this.strapiUrl}/api/live-blogs/${strapiId}`,
|
||||
{ data: { status: strapiStatus } },
|
||||
{ headers: this.getHeaders() },
|
||||
),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Successfully updated live blog ${strapiId} status in Strapi to: ${status}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to update live blog ${strapiId} status in Strapi:`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await this.syncSingleArticle(data.documentId);
|
||||
async deleteArticleFromStrapi(strapiId: string): Promise<void> {
|
||||
try {
|
||||
this.logger.log(`Deleting article ${strapiId} from Strapi`);
|
||||
|
||||
await lastValueFrom(
|
||||
this.httpService.delete(`${this.strapiUrl}/api/articles/${strapiId}`, {
|
||||
headers: this.getHeaders(),
|
||||
}),
|
||||
);
|
||||
|
||||
this.logger.log(`Successfully deleted article ${strapiId} from Strapi`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to delete article ${strapiId} from Strapi:`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteLiveBlogFromStrapi(strapiId: string): Promise<void> {
|
||||
try {
|
||||
this.logger.log(`Deleting live blog ${strapiId} from Strapi`);
|
||||
|
||||
await lastValueFrom(
|
||||
this.httpService.delete(
|
||||
`${this.strapiUrl}/api/live-blogs/${strapiId}`,
|
||||
{ headers: this.getHeaders() },
|
||||
),
|
||||
);
|
||||
|
||||
this.logger.log(`Successfully deleted live blog ${strapiId} from Strapi`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to delete live blog ${strapiId} from Strapi:`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
104
backend/src/modules/users/user.controller.ts
Normal file
104
backend/src/modules/users/user.controller.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
UseGuards,
|
||||
Request,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { UserService } from './user.service';
|
||||
import {
|
||||
CreateUserDto,
|
||||
UpdateUserDto,
|
||||
UserResponseDto,
|
||||
ChangePasswordDto,
|
||||
} from './user.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { UserRole } from '../entities';
|
||||
import type { RequestWithUser } from '../auth/types';
|
||||
|
||||
@Controller('users')
|
||||
export class UserController {
|
||||
constructor(private readonly userService: UserService) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
async create(@Body() createUserDto: CreateUserDto): Promise<UserResponseDto> {
|
||||
return this.userService.create(createUserDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
async findAll(): Promise<UserResponseDto[]> {
|
||||
return this.userService.findAll();
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getProfile(@Request() req: RequestWithUser): Promise<UserResponseDto> {
|
||||
return this.userService.findOne(req.user.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
async findOne(@Param('id') id: string): Promise<UserResponseDto> {
|
||||
return this.userService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() updateUserDto: UpdateUserDto,
|
||||
): Promise<UserResponseDto> {
|
||||
return this.userService.update(id, updateUserDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id') id: string): Promise<void> {
|
||||
return this.userService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/change-password')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async changePassword(
|
||||
@Param('id') id: string,
|
||||
@Body() changePasswordDto: ChangePasswordDto,
|
||||
@Request() req: RequestWithUser,
|
||||
): Promise<void> {
|
||||
// Users can only change their own password, unless they're admin
|
||||
if (req.user.role !== UserRole.ADMIN && req.user.id !== id) {
|
||||
throw new Error('You can only change your own password');
|
||||
}
|
||||
return this.userService.changePassword(id, changePasswordDto);
|
||||
}
|
||||
|
||||
@Patch(':id/deactivate')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
async deactivate(@Param('id') id: string): Promise<UserResponseDto> {
|
||||
return this.userService.update(id, { isActive: false });
|
||||
}
|
||||
|
||||
@Patch(':id/activate')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
async activate(@Param('id') id: string): Promise<UserResponseDto> {
|
||||
return this.userService.update(id, { isActive: true });
|
||||
}
|
||||
}
|
||||
111
backend/src/modules/users/user.dto.ts
Normal file
111
backend/src/modules/users/user.dto.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import {
|
||||
IsEmail,
|
||||
IsString,
|
||||
IsEnum,
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
import { UserRole } from '../entities';
|
||||
|
||||
interface UserEntity {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
role: UserRole;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(30)
|
||||
@Matches(/^[a-zA-Z0-9_]+$/, {
|
||||
message: 'Username can only contain letters, numbers, and underscores',
|
||||
})
|
||||
username: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password: string;
|
||||
|
||||
@IsEnum(UserRole)
|
||||
@IsOptional()
|
||||
role?: UserRole = UserRole.USER;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isActive?: boolean = true;
|
||||
}
|
||||
|
||||
export class UpdateUserDto {
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
email?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(30)
|
||||
@Matches(/^[a-zA-Z0-9_]+$/, {
|
||||
message: 'Username can only contain letters, numbers, and underscores',
|
||||
})
|
||||
@IsOptional()
|
||||
username?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
@IsOptional()
|
||||
password?: string;
|
||||
|
||||
@IsEnum(UserRole)
|
||||
@IsOptional()
|
||||
role?: UserRole;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class LoginUserDto {
|
||||
@IsString()
|
||||
username: string;
|
||||
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class UserResponseDto {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
role: UserRole;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(user: UserEntity) {
|
||||
this.id = user.id;
|
||||
this.email = user.email;
|
||||
this.username = user.username;
|
||||
this.role = user.role;
|
||||
this.isActive = user.isActive;
|
||||
this.createdAt = user.createdAt;
|
||||
this.updatedAt = user.updatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@IsString()
|
||||
currentPassword: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
newPassword: string;
|
||||
}
|
||||
13
backend/src/modules/users/user.module.ts
Normal file
13
backend/src/modules/users/user.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { User } from '../entities';
|
||||
import { UserService } from './user.service';
|
||||
import { UserController } from './user.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
controllers: [UserController],
|
||||
providers: [UserService],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
||||
212
backend/src/modules/users/user.service.ts
Normal file
212
backend/src/modules/users/user.service.ts
Normal file
@ -0,0 +1,212 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { User, UserRole } from '../entities';
|
||||
import {
|
||||
CreateUserDto,
|
||||
UpdateUserDto,
|
||||
UserResponseDto,
|
||||
ChangePasswordDto,
|
||||
} from './user.dto';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private userRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
async create(createUserDto: CreateUserDto): Promise<UserResponseDto> {
|
||||
// Check if email already exists
|
||||
const existingEmail = await this.userRepository.findOne({
|
||||
where: { email: createUserDto.email },
|
||||
});
|
||||
if (existingEmail) {
|
||||
throw new ConflictException('Email already exists');
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
const existingUsername = await this.userRepository.findOne({
|
||||
where: { username: createUserDto.username },
|
||||
});
|
||||
if (existingUsername) {
|
||||
throw new ConflictException('Username already exists');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const saltRounds = 10;
|
||||
const passwordHash = await bcrypt.hash(createUserDto.password, saltRounds);
|
||||
|
||||
// Create user
|
||||
const user = this.userRepository.create({
|
||||
...createUserDto,
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
const savedUser = await this.userRepository.save(user);
|
||||
return new UserResponseDto(savedUser);
|
||||
}
|
||||
|
||||
async findAll(): Promise<UserResponseDto[]> {
|
||||
const users = await this.userRepository.find({
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
return users.map((user) => new UserResponseDto(user));
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<UserResponseDto> {
|
||||
const user = await this.userRepository.findOne({ where: { id } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
return new UserResponseDto(user);
|
||||
}
|
||||
|
||||
async findByUsername(username: string): Promise<User | null> {
|
||||
return this.userRepository.findOne({ where: { username } });
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.userRepository.findOne({ where: { email } });
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
updateUserDto: UpdateUserDto,
|
||||
): Promise<UserResponseDto> {
|
||||
const user = await this.userRepository.findOne({ where: { id } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// Check if email is being changed and if it already exists
|
||||
if (updateUserDto.email && updateUserDto.email !== user.email) {
|
||||
const existingEmail = await this.userRepository.findOne({
|
||||
where: { email: updateUserDto.email },
|
||||
});
|
||||
if (existingEmail) {
|
||||
throw new ConflictException('Email already exists');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if username is being changed and if it already exists
|
||||
if (updateUserDto.username && updateUserDto.username !== user.username) {
|
||||
const existingUsername = await this.userRepository.findOne({
|
||||
where: { username: updateUserDto.username },
|
||||
});
|
||||
if (existingUsername) {
|
||||
throw new ConflictException('Username already exists');
|
||||
}
|
||||
}
|
||||
|
||||
// Hash new password if provided
|
||||
if (updateUserDto.password) {
|
||||
const saltRounds = 10;
|
||||
updateUserDto.password = await bcrypt.hash(
|
||||
updateUserDto.password,
|
||||
saltRounds,
|
||||
);
|
||||
}
|
||||
|
||||
// Update user
|
||||
Object.assign(user, updateUserDto);
|
||||
if (updateUserDto.password) {
|
||||
user.passwordHash = updateUserDto.password;
|
||||
delete updateUserDto.password;
|
||||
}
|
||||
|
||||
const updatedUser = await this.userRepository.save(user);
|
||||
return new UserResponseDto(updatedUser);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const user = await this.userRepository.findOne({ where: { id } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// Prevent deleting the last admin
|
||||
if (user.role === UserRole.ADMIN) {
|
||||
const adminCount = await this.userRepository.count({
|
||||
where: { role: UserRole.ADMIN },
|
||||
});
|
||||
if (adminCount <= 1) {
|
||||
throw new BadRequestException('Cannot delete the last admin user');
|
||||
}
|
||||
}
|
||||
|
||||
await this.userRepository.remove(user);
|
||||
}
|
||||
|
||||
async changePassword(
|
||||
id: string,
|
||||
changePasswordDto: ChangePasswordDto,
|
||||
): Promise<void> {
|
||||
const user = await this.userRepository.findOne({ where: { id } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const isPasswordValid = await bcrypt.compare(
|
||||
changePasswordDto.currentPassword,
|
||||
user.passwordHash,
|
||||
);
|
||||
if (!isPasswordValid) {
|
||||
throw new BadRequestException('Current password is incorrect');
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const saltRounds = 10;
|
||||
user.passwordHash = await bcrypt.hash(
|
||||
changePasswordDto.newPassword,
|
||||
saltRounds,
|
||||
);
|
||||
|
||||
await this.userRepository.save(user);
|
||||
}
|
||||
|
||||
async validateUser(username: string, password: string): Promise<User | null> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { username, isActive: true },
|
||||
});
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!isPasswordValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async createAdminIfNotExists(): Promise<void> {
|
||||
const adminExists = await this.userRepository.findOne({
|
||||
where: { role: UserRole.ADMIN },
|
||||
});
|
||||
|
||||
if (!adminExists) {
|
||||
const saltRounds = 10;
|
||||
const passwordHash = await bcrypt.hash('admin123', saltRounds);
|
||||
|
||||
const adminUser = this.userRepository.create({
|
||||
email: 'admin@placebo.mk',
|
||||
username: 'admin',
|
||||
passwordHash,
|
||||
role: UserRole.ADMIN,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
await this.userRepository.save(adminUser);
|
||||
console.log('Default admin user created: admin / admin123');
|
||||
}
|
||||
}
|
||||
}
|
||||
20
cms/cms/.env
Normal file
20
cms/cms/.env
Normal file
@ -0,0 +1,20 @@
|
||||
|
||||
# Server
|
||||
HOST=0.0.0.0
|
||||
PORT=1337
|
||||
|
||||
# Secrets (these should be overridden by docker env vars)
|
||||
APP_KEYS=tobereplaced
|
||||
API_TOKEN_SALT=tobereplaced
|
||||
ADMIN_JWT_SECRET=tobereplaced
|
||||
TRANSFER_TOKEN_SALT=tobereplaced
|
||||
JWT_SECRET=tobereplaced
|
||||
|
||||
# Database (these MUST be overridden by docker env vars)
|
||||
DATABASE_CLIENT=postgres
|
||||
DATABASE_HOST=postgres-cms
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_NAME=placebo_cms_db
|
||||
DATABASE_USERNAME=placebo_user
|
||||
DATABASE_PASSWORD=placebo_pass
|
||||
DATABASE_SSL=false
|
||||
72
cms/cms/Dockerfile
Normal file
72
cms/cms/Dockerfile
Normal file
@ -0,0 +1,72 @@
|
||||
# CMS Dockerfile for Placebo.mk Strapi CMS
|
||||
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install --legacy-peer-deps
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build Strapi
|
||||
RUN npm run build
|
||||
|
||||
# Prune devDependencies after build (suppress warning)
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install su-exec for user switching in entrypoint
|
||||
RUN apk add --no-cache su-exec
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nodejs -u 1001
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/dist/config ./config
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/src ./src
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/favicon.png ./favicon.png
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/.strapi ./.strapi
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Create the directory structure Strapi expects for the admin build
|
||||
RUN mkdir -p /app/node_modules/@strapi/admin/dist/server/server && \
|
||||
ln -sf /app/dist/build /app/node_modules/@strapi/admin/dist/server/server/build && \
|
||||
chown -R nodejs:nodejs /app/node_modules/@strapi/admin
|
||||
|
||||
# Create data and database directories with proper permissions
|
||||
RUN mkdir -p /app/.tmp /app/database /app/database/migrations /app/public/uploads && \
|
||||
chown -R nodejs:nodejs /app
|
||||
|
||||
# Don't switch to nodejs user yet - entrypoint will handle it
|
||||
# USER nodejs
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:1337/_health', (r) => {if(r.statusCode !== 200) process.exit(1)})"
|
||||
|
||||
# Expose port
|
||||
EXPOSE 1337
|
||||
|
||||
# Use entrypoint to fix permissions on startup then switch to nodejs user
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
|
||||
# Start Strapi
|
||||
CMD ["npm", "run", "start"]
|
||||
35
cms/cms/Dockerfile.dev
Normal file
35
cms/cms/Dockerfile.dev
Normal file
@ -0,0 +1,35 @@
|
||||
# CMS Development Dockerfile for Placebo.mk Strapi CMS
|
||||
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python and build tools for any native modules
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install dependencies with better error handling
|
||||
COPY package*.json ./
|
||||
COPY package-lock.json* ./
|
||||
|
||||
# Fix permissions before npm install
|
||||
RUN chown -R node:node /app
|
||||
|
||||
# Switch to non-root user for npm install
|
||||
USER node
|
||||
|
||||
# Clear npm cache and install dependencies
|
||||
RUN npm cache clean --force && \
|
||||
npm install
|
||||
|
||||
# Copy source code as node user
|
||||
COPY --chown=node:node . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 1337
|
||||
|
||||
# Start development server
|
||||
CMD ["npm", "run", "develop"]
|
||||
1
cms/cms/cmstoken.md
Normal file
1
cms/cms/cmstoken.md
Normal file
@ -0,0 +1 @@
|
||||
ad31a55be0b5efc2d6d7b5490e8d1a31b1a3a2aecbcf99e8f65bcb44bd0189e2918388b942fb3ac7b35fd470d19d475e556489b773c68756977e452aec1ab83f34876ed76ebadafce31636d5621c66820425d1105d753cdc5452d8f3d503ddbaebf45fc6817c235e1f8eae12d118452951ee0a48691446475f7ffc6a72fd6ffb
|
||||
@ -17,4 +17,9 @@ export default ({ env }) => ({
|
||||
nps: env.bool('FLAG_NPS', true),
|
||||
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
|
||||
},
|
||||
// Specify the build directory path for production
|
||||
path: env('ADMIN_PATH', '/admin'),
|
||||
build: {
|
||||
backend: env('PUBLIC_URL', 'https://cms.placebo.mk'),
|
||||
},
|
||||
});
|
||||
|
||||
@ -3,58 +3,51 @@ import path from 'path';
|
||||
export default ({ env }) => {
|
||||
const client = env('DATABASE_CLIENT', 'sqlite');
|
||||
|
||||
const connections = {
|
||||
mysql: {
|
||||
console.log('=== DATABASE CONFIGURATION ===');
|
||||
console.log('DATABASE_CLIENT:', client);
|
||||
console.log('DATABASE_HOST:', env('DATABASE_HOST', 'not-set'));
|
||||
console.log('DATABASE_PORT:', env('DATABASE_PORT', 'not-set'));
|
||||
console.log('DATABASE_USERNAME:', env('DATABASE_USERNAME', 'not-set'));
|
||||
console.log('DATABASE_NAME:', env('DATABASE_NAME', 'not-set'));
|
||||
console.log('DATABASE_PASSWORD:', env('DATABASE_PASSWORD') ? '***SET***' : '***NOT SET***');
|
||||
console.log('DATABASE_SSL:', env('DATABASE_SSL', 'not-set'));
|
||||
|
||||
if (client === 'postgres') {
|
||||
const connectionConfig = {
|
||||
host: env('DATABASE_HOST', 'localhost'),
|
||||
port: env.int('DATABASE_PORT', 5432),
|
||||
database: env('DATABASE_NAME', 'strapi'),
|
||||
user: env('DATABASE_USERNAME', 'strapi'),
|
||||
password: env('DATABASE_PASSWORD', 'strapi'),
|
||||
ssl: env.bool('DATABASE_SSL', false) ? { rejectUnauthorized: false } : false,
|
||||
};
|
||||
|
||||
console.log('PostgreSQL connection config:', JSON.stringify({
|
||||
...connectionConfig,
|
||||
password: connectionConfig.password ? `***${connectionConfig.password.length} chars***` : '***NOT SET***'
|
||||
}, null, 2));
|
||||
|
||||
return {
|
||||
connection: {
|
||||
host: env('DATABASE_HOST', 'localhost'),
|
||||
port: env.int('DATABASE_PORT', 3306),
|
||||
database: env('DATABASE_NAME', 'strapi'),
|
||||
user: env('DATABASE_USERNAME', 'strapi'),
|
||||
password: env('DATABASE_PASSWORD', 'strapi'),
|
||||
ssl: env.bool('DATABASE_SSL', false) && {
|
||||
key: env('DATABASE_SSL_KEY', undefined),
|
||||
cert: env('DATABASE_SSL_CERT', undefined),
|
||||
ca: env('DATABASE_SSL_CA', undefined),
|
||||
capath: env('DATABASE_SSL_CAPATH', undefined),
|
||||
cipher: env('DATABASE_SSL_CIPHER', undefined),
|
||||
rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true),
|
||||
client: 'postgres',
|
||||
connection: connectionConfig,
|
||||
pool: {
|
||||
min: env.int('DATABASE_POOL_MIN', 2),
|
||||
max: env.int('DATABASE_POOL_MAX', 10),
|
||||
},
|
||||
acquireConnectionTimeout: env.int('DATABASE_TIMEOUT', 60000),
|
||||
},
|
||||
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
|
||||
},
|
||||
postgres: {
|
||||
connection: {
|
||||
connectionString: env('DATABASE_URL'),
|
||||
host: env('DATABASE_HOST', 'localhost'),
|
||||
port: env.int('DATABASE_PORT', 5432),
|
||||
database: env('DATABASE_NAME', 'strapi'),
|
||||
user: env('DATABASE_USERNAME', 'strapi'),
|
||||
password: env('DATABASE_PASSWORD', 'strapi'),
|
||||
ssl: env.bool('DATABASE_SSL', false) && {
|
||||
key: env('DATABASE_SSL_KEY', undefined),
|
||||
cert: env('DATABASE_SSL_CERT', undefined),
|
||||
ca: env('DATABASE_SSL_CA', undefined),
|
||||
capath: env('DATABASE_SSL_CAPATH', undefined),
|
||||
cipher: env('DATABASE_SSL_CIPHER', undefined),
|
||||
rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true),
|
||||
},
|
||||
schema: env('DATABASE_SCHEMA', 'public'),
|
||||
},
|
||||
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
|
||||
},
|
||||
sqlite: {
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Using SQLite configuration');
|
||||
return {
|
||||
connection: {
|
||||
client: 'sqlite',
|
||||
connection: {
|
||||
filename: path.join(__dirname, '..', '..', env('DATABASE_FILENAME', '.tmp/data.db')),
|
||||
},
|
||||
useNullAsDefault: true,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
connection: {
|
||||
client,
|
||||
...connections[client],
|
||||
acquireConnectionTimeout: env.int('DATABASE_CONNECTION_TIMEOUT', 60000),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@ -5,7 +5,17 @@ export default [
|
||||
'strapi::cors',
|
||||
'strapi::poweredBy',
|
||||
'strapi::query',
|
||||
'strapi::body',
|
||||
{
|
||||
name: 'strapi::body',
|
||||
config: {
|
||||
formLimit: '256mb', // Max form size
|
||||
jsonLimit: '256mb', // Max JSON payload size
|
||||
textLimit: '256mb', // Max text payload size
|
||||
formidable: {
|
||||
maxFileSize: 200 * 1024 * 1024, // 200MB in bytes
|
||||
},
|
||||
},
|
||||
},
|
||||
'strapi::session',
|
||||
'strapi::favicon',
|
||||
'strapi::public',
|
||||
|
||||
@ -1 +1,7 @@
|
||||
export default () => ({});
|
||||
export default ({ env }) => ({
|
||||
upload: {
|
||||
config: {
|
||||
sizeLimit: 200 * 1024 * 1024, // 200MB in bytes
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
export default ({ env }) => ({
|
||||
host: env('HOST', '0.0.0.0'),
|
||||
port: env.int('PORT', 1337),
|
||||
url: env('PUBLIC_URL', 'https://cms.placebo.mk'),
|
||||
app: {
|
||||
keys: env.array('APP_KEYS'),
|
||||
},
|
||||
|
||||
12
cms/cms/docker-entrypoint.sh
Normal file
12
cms/cms/docker-entrypoint.sh
Normal file
@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Ensure uploads directory exists and is writable
|
||||
mkdir -p /app/public/uploads
|
||||
|
||||
# Fix permissions for uploads directory (needed for volume mounts)
|
||||
chown -R nodejs:nodejs /app/public/uploads
|
||||
chmod -R 755 /app/public/uploads
|
||||
|
||||
# Switch to nodejs user and execute the main command
|
||||
exec su-exec nodejs "$@"
|
||||
2
cms/cms/envprodstrapi.md
Normal file
2
cms/cms/envprodstrapi.md
Normal file
@ -0,0 +1,2 @@
|
||||
api token=5af45b836a0bcd065b528963e62b6d5d325117dfbf179cd5123087a17906c911fd67bcb57d92f11e869ebda27339b21aa3a7221d3dbbe79de51ef2f9e2af4dae0befe6528872ca2f68f64656ed45c7dfcacea36fdb55d1d9eb2fd9275454ac7ff8ab9acb1535f62449b3f8bd75c24803a2bd0714c637fd1d0bc819798723d999
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
"@strapi/plugin-cloud": "5.33.0",
|
||||
"@strapi/plugin-users-permissions": "5.33.0",
|
||||
"@strapi/strapi": "5.33.0",
|
||||
"better-sqlite3": "12.4.1",
|
||||
"pg": "^8.13.3",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"react-router-dom": "^6.0.0",
|
||||
|
||||
38
cms/cms/scripts/create-api-token.js
Normal file
38
cms/cms/scripts/create-api-token.js
Normal file
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Script to create an API token for backend integration
|
||||
* Run this inside the CMS container if the UI doesn't work
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
async function createApiToken() {
|
||||
const strapi = await factories.createCoreStore()();
|
||||
|
||||
try {
|
||||
// Create API token
|
||||
const tokenService = strapi.service('admin::api-token');
|
||||
|
||||
const token = await tokenService.create({
|
||||
name: 'Backend Integration',
|
||||
description: 'Token for backend to fetch articles',
|
||||
type: 'read-only',
|
||||
permissions: [],
|
||||
lifespan: null, // Unlimited
|
||||
});
|
||||
|
||||
console.log('✅ API Token created successfully!');
|
||||
console.log('\nToken details:');
|
||||
console.log('Name:', token.name);
|
||||
console.log('Type:', token.type);
|
||||
console.log('\nTOKEN (copy this to Coolify as STRAPI_API_TOKEN):');
|
||||
console.log(token.accessKey);
|
||||
console.log('\n⚠️ Save this token - you won\'t see it again!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating API token:', error.message);
|
||||
} finally {
|
||||
await strapi.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
createApiToken();
|
||||
@ -4,7 +4,8 @@
|
||||
"info": {
|
||||
"singularName": "article",
|
||||
"pluralName": "articles",
|
||||
"displayName": "article"
|
||||
"displayName": "Article",
|
||||
"description": "News articles for Placebo.mk"
|
||||
},
|
||||
"options": {
|
||||
"draftAndPublish": true
|
||||
@ -12,23 +13,61 @@
|
||||
"pluginOptions": {},
|
||||
"attributes": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
"slug": {
|
||||
"type": "uid",
|
||||
"targetField": "title",
|
||||
"required": true
|
||||
},
|
||||
"description": {
|
||||
"type": "text"
|
||||
},
|
||||
"content": {
|
||||
"type": "richtext"
|
||||
},
|
||||
"author": {
|
||||
"type": "string"
|
||||
},
|
||||
"img": {
|
||||
"type": "media",
|
||||
"multiple": false,
|
||||
"required": false,
|
||||
"allowedTypes": ["images"]
|
||||
},
|
||||
"imagePosition": {
|
||||
"type": "enumeration",
|
||||
"enum": ["top", "left", "right", "none"],
|
||||
"default": "top"
|
||||
},
|
||||
"imageSize": {
|
||||
"type": "enumeration",
|
||||
"enum": ["small", "medium", "large"],
|
||||
"default": "medium"
|
||||
},
|
||||
"media": {
|
||||
"type": "media",
|
||||
"multiple": true,
|
||||
"allowedTypes": [
|
||||
"images",
|
||||
"files",
|
||||
"videos",
|
||||
"audios"
|
||||
]
|
||||
"required": false,
|
||||
"allowedTypes": ["images", "files", "videos", "audios"]
|
||||
},
|
||||
"author": {
|
||||
"videoUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"videoPosition": {
|
||||
"type": "enumeration",
|
||||
"enum": ["top", "inline", "bottom", "none"],
|
||||
"default": "inline"
|
||||
},
|
||||
"videoCaption": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "enumeration",
|
||||
"enum": ["general", "sport", "art", "science"],
|
||||
"default": "general",
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
7
cms/cms/src/api/article/controllers/article.js
Normal file
7
cms/cms/src/api/article/controllers/article.js
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* article controller
|
||||
*/
|
||||
|
||||
const { createCoreController } = require('@strapi/strapi').factories;
|
||||
|
||||
module.exports = createCoreController('api::article.article');
|
||||
@ -1,7 +0,0 @@
|
||||
/**
|
||||
* article controller
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreController('api::article.article');
|
||||
7
cms/cms/src/api/article/routes/article.js
Normal file
7
cms/cms/src/api/article/routes/article.js
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* article router
|
||||
*/
|
||||
|
||||
const { createCoreRouter } = require('@strapi/strapi').factories;
|
||||
|
||||
module.exports = createCoreRouter('api::article.article');
|
||||
@ -1,7 +0,0 @@
|
||||
/**
|
||||
* article router
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreRouter('api::article.article');
|
||||
7
cms/cms/src/api/article/services/article.js
Normal file
7
cms/cms/src/api/article/services/article.js
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* article service
|
||||
*/
|
||||
|
||||
const { createCoreService } = require('@strapi/strapi').factories;
|
||||
|
||||
module.exports = createCoreService('api::article.article');
|
||||
@ -1,7 +0,0 @@
|
||||
/**
|
||||
* article service
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreService('api::article.article');
|
||||
@ -16,5 +16,9 @@ export default {
|
||||
* This gives you an opportunity to set up your data model,
|
||||
* run jobs, or perform some special logic.
|
||||
*/
|
||||
bootstrap(/* { strapi }: { strapi: Core.Strapi } */) {},
|
||||
async bootstrap({ strapi }) {
|
||||
console.log('=== Strapi Bootstrap ===');
|
||||
console.log('Available content types:', Object.keys(strapi.contentTypes || {}));
|
||||
console.log('Article content type exists:', !!strapi.contentTypes['api::article.article']);
|
||||
},
|
||||
};
|
||||
|
||||
13
cms/cms/types/generated/contentTypes.d.ts
vendored
13
cms/cms/types/generated/contentTypes.d.ts
vendored
@ -446,6 +446,13 @@ export interface ApiArticleArticle extends Struct.CollectionTypeSchema {
|
||||
createdAt: Schema.Attribute.DateTime;
|
||||
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||
Schema.Attribute.Private;
|
||||
imagePosition: Schema.Attribute.Enumeration<
|
||||
['top', 'left', 'right', 'none']
|
||||
> &
|
||||
Schema.Attribute.DefaultTo<'top'>;
|
||||
imageSize: Schema.Attribute.Enumeration<['small', 'medium', 'large']> &
|
||||
Schema.Attribute.DefaultTo<'medium'>;
|
||||
img: Schema.Attribute.Media<'images' | 'files' | 'videos' | 'audios'>;
|
||||
locale: Schema.Attribute.String & Schema.Attribute.Private;
|
||||
localizations: Schema.Attribute.Relation<
|
||||
'oneToMany',
|
||||
@ -461,6 +468,12 @@ export interface ApiArticleArticle extends Struct.CollectionTypeSchema {
|
||||
updatedAt: Schema.Attribute.DateTime;
|
||||
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||
Schema.Attribute.Private;
|
||||
videoCaption: Schema.Attribute.String & Schema.Attribute.DefaultTo<''>;
|
||||
videoPosition: Schema.Attribute.Enumeration<
|
||||
['top', 'inline', 'bottom', 'none']
|
||||
> &
|
||||
Schema.Attribute.DefaultTo<'inline'>;
|
||||
videoUrl: Schema.Attribute.String & Schema.Attribute.DefaultTo<''>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
289
docker-compose.coolify.yml
Normal file
289
docker-compose.coolify.yml
Normal file
@ -0,0 +1,289 @@
|
||||
# Docker Compose for Coolify Deployment
|
||||
# Deploy all services in one run
|
||||
#
|
||||
# Usage in Coolify:
|
||||
# 1. Create new Docker Compose service
|
||||
# 2. Point to this file
|
||||
# 3. Set environment variables in Coolify UI
|
||||
# 4. Deploy
|
||||
|
||||
services:
|
||||
# ===========================================
|
||||
# DATABASES
|
||||
# ===========================================
|
||||
|
||||
postgres-backend:
|
||||
image: postgres:16-alpine
|
||||
container_name: placebo-postgres-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: placebo_backend_db
|
||||
POSTGRES_USER: placebo_user
|
||||
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
|
||||
volumes:
|
||||
- placebo-postgres-backend-data:/var/lib/postgresql/data
|
||||
expose:
|
||||
- "5432"
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U placebo_user -d placebo_backend_db']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
networks:
|
||||
- placebo-internal
|
||||
|
||||
postgres-cms:
|
||||
image: postgres:16-alpine
|
||||
container_name: placebo-postgres-cms
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: placebo_cms_db
|
||||
POSTGRES_USER: placebo_user
|
||||
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
|
||||
volumes:
|
||||
- placebo-postgres-cms-data:/var/lib/postgresql/data
|
||||
expose:
|
||||
- "5432"
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U placebo_user -d placebo_cms_db']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
networks:
|
||||
- placebo-internal
|
||||
|
||||
# ===========================================
|
||||
# BACKEND (NestJS API)
|
||||
# ===========================================
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: placebo-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
DATABASE_TYPE: postgres
|
||||
DATABASE_HOST: postgres-backend
|
||||
DATABASE_PORT: 5432
|
||||
DATABASE_USERNAME: placebo_user
|
||||
DATABASE_PASSWORD: ${DATABASE_PASSWORD}
|
||||
DATABASE_NAME: placebo_backend_db
|
||||
DATABASE_SYNCHRONIZE: 'true'
|
||||
DATABASE_LOGGING: 'true'
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
JWT_EXPIRATION: '86400'
|
||||
FRONTEND_URL: https://placebo.mk
|
||||
PWA_URL: https://app.placebo.mk
|
||||
STRAPI_URL: http://cms:1337
|
||||
STRAPI_PUBLIC_URL: https://cms.placebo.mk
|
||||
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:contact@placebo.mk}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||
depends_on:
|
||||
postgres-backend:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ['CMD', 'node', '-e', "require('http').get('http://127.0.0.1:3000/api/v1/health', (r) => {if(r.statusCode !== 200) process.exit(1)})"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- placebo-internal
|
||||
- coolify
|
||||
expose:
|
||||
- "3000"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# HTTP router (for Let's Encrypt challenge and redirect)
|
||||
- "traefik.http.routers.backend-http.rule=Host(`api.placebo.mk`)"
|
||||
- "traefik.http.routers.backend-http.entrypoints=http"
|
||||
- "traefik.http.routers.backend-http.middlewares=redirect-to-https"
|
||||
# HTTPS router
|
||||
- "traefik.http.routers.backend.rule=Host(`api.placebo.mk`)"
|
||||
- "traefik.http.routers.backend.entrypoints=https"
|
||||
- "traefik.http.routers.backend.tls=true"
|
||||
- "traefik.http.routers.backend.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.backend.service=backend"
|
||||
# Service
|
||||
- "traefik.http.services.backend.loadbalancer.server.port=3000"
|
||||
# Redirect middleware
|
||||
- "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.redirect-to-https.redirectscheme.permanent=true"
|
||||
|
||||
# ===========================================
|
||||
# CMS (Strapi)
|
||||
# ===========================================
|
||||
|
||||
cms:
|
||||
build:
|
||||
context: ./cms/cms
|
||||
dockerfile: Dockerfile
|
||||
container_name: placebo-cms
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
HOST: 0.0.0.0
|
||||
PORT: 1337
|
||||
PUBLIC_URL: https://cms.placebo.mk
|
||||
BACKEND_WEBHOOK_URL: http://backend:3000
|
||||
DATABASE_CLIENT: postgres
|
||||
DATABASE_HOST: postgres-cms
|
||||
DATABASE_PORT: '5432'
|
||||
DATABASE_NAME: placebo_cms_db
|
||||
DATABASE_USERNAME: placebo_user
|
||||
DATABASE_PASSWORD: ${DATABASE_PASSWORD}
|
||||
DATABASE_SSL: 'false'
|
||||
APP_KEYS: ${STRAPI_APP_KEYS}
|
||||
API_TOKEN_SALT: ${STRAPI_API_TOKEN_SALT}
|
||||
ADMIN_JWT_SECRET: ${STRAPI_ADMIN_JWT_SECRET}
|
||||
TRANSFER_TOKEN_SALT: ${STRAPI_TRANSFER_TOKEN_SALT}
|
||||
JWT_SECRET: ${STRAPI_JWT_SECRET}
|
||||
ENCRYPTION_KEY: ${STRAPI_ENCRYPTION_KEY}
|
||||
depends_on:
|
||||
postgres-cms:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- placebo-cms-uploads:/app/public/uploads
|
||||
healthcheck:
|
||||
test: ['CMD', 'node', '-e', "require('http').get('http://127.0.0.1:1337/_health', (r) => {if(r.statusCode === 200 || r.statusCode === 204) process.exit(0); process.exit(1)})"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
networks:
|
||||
- placebo-internal
|
||||
- coolify
|
||||
expose:
|
||||
- "1337"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# HTTP router (for Let's Encrypt challenge and redirect)
|
||||
- "traefik.http.routers.cms-http.rule=Host(`cms.placebo.mk`)"
|
||||
- "traefik.http.routers.cms-http.entrypoints=http"
|
||||
- "traefik.http.routers.cms-http.middlewares=redirect-to-https"
|
||||
# HTTPS router
|
||||
- "traefik.http.routers.cms.rule=Host(`cms.placebo.mk`)"
|
||||
- "traefik.http.routers.cms.entrypoints=https"
|
||||
- "traefik.http.routers.cms.tls=true"
|
||||
- "traefik.http.routers.cms.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.cms.service=cms"
|
||||
# Service configuration
|
||||
- "traefik.http.services.cms.loadbalancer.server.port=1337"
|
||||
- "traefik.http.services.cms.loadbalancer.passhostheader=true"
|
||||
- "traefik.http.services.cms.loadbalancer.responseForwarding.flushInterval=100ms"
|
||||
# Health check for service
|
||||
- "traefik.http.services.cms.loadbalancer.healthcheck.path=/_health"
|
||||
- "traefik.http.services.cms.loadbalancer.healthcheck.interval=30s"
|
||||
- "traefik.http.services.cms.loadbalancer.healthcheck.timeout=5s"
|
||||
|
||||
# ===========================================
|
||||
# FRONTEND (React)
|
||||
# ===========================================
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_URL: https://api.placebo.mk/api/v1
|
||||
VITE_CMS_URL: https://cms.placebo.mk
|
||||
VITE_PUBLIC_POSTHOG_KEY: phc_kuDGT1kUmJmijLRjiQaKWIaU4JBdFW0aELW2hIGlBXR
|
||||
VITE_PUBLIC_POSTHOG_HOST: https://eu.i.posthog.com
|
||||
container_name: placebo-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://127.0.0.1:80/']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- placebo-internal
|
||||
- coolify
|
||||
expose:
|
||||
- "80"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# HTTP router (for Let's Encrypt challenge and redirect)
|
||||
- "traefik.http.routers.frontend-http.rule=Host(`placebo.mk`) || Host(`www.placebo.mk`)"
|
||||
- "traefik.http.routers.frontend-http.entrypoints=http"
|
||||
- "traefik.http.routers.frontend-http.middlewares=redirect-to-https"
|
||||
# HTTPS router
|
||||
- "traefik.http.routers.frontend.rule=Host(`placebo.mk`) || Host(`www.placebo.mk`)"
|
||||
- "traefik.http.routers.frontend.entrypoints=https"
|
||||
- "traefik.http.routers.frontend.tls=true"
|
||||
- "traefik.http.routers.frontend.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.frontend.service=frontend"
|
||||
# Service
|
||||
- "traefik.http.services.frontend.loadbalancer.server.port=80"
|
||||
|
||||
# ===========================================
|
||||
# PWA (Progressive Web App)
|
||||
# ===========================================
|
||||
|
||||
pwa:
|
||||
build:
|
||||
context: ./pwa
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_URL: https://api.placebo.mk/api/v1
|
||||
VITE_CMS_URL: https://cms.placebo.mk
|
||||
VITE_PUBLIC_POSTHOG_KEY: phc_kuDGT1kUmJmijLRjiQaKWIaU4JBdFW0aELW2hIGlBXR
|
||||
VITE_PUBLIC_POSTHOG_HOST: https://eu.i.posthog.com
|
||||
container_name: placebo-pwa
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://127.0.0.1:80/']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- placebo-internal
|
||||
- coolify
|
||||
expose:
|
||||
- "80"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# HTTP router (for Let's Encrypt challenge and redirect)
|
||||
- "traefik.http.routers.pwa-http.rule=Host(`app.placebo.mk`)"
|
||||
- "traefik.http.routers.pwa-http.entrypoints=http"
|
||||
- "traefik.http.routers.pwa-http.middlewares=redirect-to-https"
|
||||
# HTTPS router
|
||||
- "traefik.http.routers.pwa.rule=Host(`app.placebo.mk`)"
|
||||
- "traefik.http.routers.pwa.entrypoints=https"
|
||||
- "traefik.http.routers.pwa.tls=true"
|
||||
- "traefik.http.routers.pwa.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.pwa.service=pwa"
|
||||
# Service
|
||||
- "traefik.http.services.pwa.loadbalancer.server.port=80"
|
||||
|
||||
# ===========================================
|
||||
# VOLUMES (Managed by Coolify)
|
||||
# ===========================================
|
||||
|
||||
volumes:
|
||||
placebo-postgres-backend-data:
|
||||
driver: local
|
||||
placebo-postgres-cms-data:
|
||||
driver: local
|
||||
placebo-cms-uploads:
|
||||
driver: local
|
||||
|
||||
# ===========================================
|
||||
# NETWORKS
|
||||
# ===========================================
|
||||
|
||||
networks:
|
||||
placebo-internal:
|
||||
driver: bridge
|
||||
coolify:
|
||||
external: true
|
||||
115
docker-compose.dev.yml
Normal file
115
docker-compose.dev.yml
Normal file
@ -0,0 +1,115 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PostgreSQL database for development
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: placebo-postgres-dev
|
||||
environment:
|
||||
POSTGRES_USER: placebo_user
|
||||
POSTGRES_PASSWORD: placebo_password
|
||||
POSTGRES_DB: placebo_backend_db
|
||||
volumes:
|
||||
- postgres_data_dev:/var/lib/postgresql/data
|
||||
- ./scripts/init-postgres-dev.sql:/docker-entrypoint-initdb.d/init-postgres-dev.sql
|
||||
ports:
|
||||
- "5432:5432"
|
||||
networks:
|
||||
- placebo-network-dev
|
||||
|
||||
# Backend API (NestJS) - Development with hot reload
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: placebo-backend-dev
|
||||
env_file:
|
||||
- ./backend/.env.docker # Use the .env.docker file for Docker configuration
|
||||
environment:
|
||||
# Only override if needed, most config is in .env.docker file
|
||||
NODE_ENV: development
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- postgres
|
||||
volumes:
|
||||
- ./backend/src:/app/src
|
||||
- ./backend/package.json:/app/package.json
|
||||
- ./backend/package-lock.json:/app/package-lock.json
|
||||
- ./backend/.env.docker:/app/.env # Mount .env.docker file as .env in container
|
||||
command: npm run dev:docker
|
||||
networks:
|
||||
- placebo-network-dev
|
||||
|
||||
# Frontend (TanStack React) - Development with hot reload
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: placebo-frontend-dev
|
||||
env_file:
|
||||
- ./frontend/.env # Use the .env file for configuration
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
ports:
|
||||
- "5173:5173"
|
||||
depends_on:
|
||||
- backend
|
||||
volumes:
|
||||
- ./frontend/src:/app/src
|
||||
- ./frontend/public:/app/public
|
||||
- ./frontend/package.json:/app/package.json
|
||||
- ./frontend/package-lock.json:/app/package-lock.json
|
||||
- ./frontend/index.html:/app/index.html
|
||||
- ./frontend/vite.config.ts:/app/vite.config.ts
|
||||
- ./frontend/tsconfig.json:/app/tsconfig.json
|
||||
- ./frontend/tsconfig.node.json:/app/tsconfig.node.json
|
||||
- ./frontend/tailwind.config.js:/app/tailwind.config.js
|
||||
- ./frontend/.env:/app/.env
|
||||
command: npm run dev:docker
|
||||
networks:
|
||||
- placebo-network-dev
|
||||
|
||||
# CMS (Strapi) - Development with hot reload
|
||||
cms:
|
||||
build:
|
||||
context: ./cms/cms
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: placebo-cms-dev
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
DATABASE_CLIENT: postgres
|
||||
DATABASE_HOST: postgres
|
||||
DATABASE_PORT: 5432
|
||||
DATABASE_NAME: placebo_cms_db
|
||||
DATABASE_USERNAME: placebo_user
|
||||
DATABASE_PASSWORD: placebo_password
|
||||
DATABASE_SSL: "false"
|
||||
JWT_SECRET: dev-jwt-secret
|
||||
ADMIN_JWT_SECRET: dev-admin-jwt-secret
|
||||
APP_KEYS: dev-app-keys
|
||||
API_TOKEN_SALT: dev-api-token-salt
|
||||
TRANSFER_TOKEN_SALT: dev-transfer-token-salt
|
||||
CORS_ORIGIN: http://localhost:5173
|
||||
PORT: 1337
|
||||
ports:
|
||||
- "1337:1337"
|
||||
depends_on:
|
||||
- postgres
|
||||
volumes:
|
||||
- ./cms/cms/src:/app/src
|
||||
- ./cms/cms/config:/app/config
|
||||
- ./cms/cms/public:/app/public
|
||||
- ./cms/cms/package.json:/app/package.json
|
||||
- ./cms/cms/package-lock.json:/app/package-lock.json
|
||||
command: npm run develop
|
||||
networks:
|
||||
- placebo-network-dev
|
||||
|
||||
volumes:
|
||||
postgres_data_dev:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
placebo-network-dev:
|
||||
driver: bridge
|
||||
155
docker-compose.prod.yml
Normal file
155
docker-compose.prod.yml
Normal file
@ -0,0 +1,155 @@
|
||||
# Production Docker Compose for Coolify
|
||||
# Each service should be deployed separately in Coolify
|
||||
# This file is for reference and local testing only
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: placebo-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: placebo_db
|
||||
POSTGRES_USER: placebo_user
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-placebo_password}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U placebo_user -d placebo_db']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- placebo-network
|
||||
|
||||
# Backend API (NestJS)
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: placebo-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_TYPE: postgres
|
||||
DATABASE_HOST: postgres
|
||||
DATABASE_PORT: 5432
|
||||
DATABASE_USERNAME: placebo_user
|
||||
DATABASE_PASSWORD: ${POSTGRES_PASSWORD:-placebo_password}
|
||||
DATABASE_NAME: placebo_db
|
||||
DATABASE_SYNCHRONIZE: 'false'
|
||||
DATABASE_LOGGING: 'false'
|
||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||
JWT_EXPIRATION: '86400'
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3001}
|
||||
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:contact@placebo.mk}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||
ports:
|
||||
- '3000:3000'
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ['CMD', 'node', '-e', "require('http').get('http://localhost:3000/health', (r) => {if(r.statusCode !== 200) process.exit(1)})"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- placebo-network
|
||||
|
||||
# Frontend (React)
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_URL: ${VITE_API_URL:-http://localhost:3000/api/v1}
|
||||
VITE_CMS_URL: ${VITE_CMS_URL:-http://localhost:1337}
|
||||
container_name: placebo-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3001:80'
|
||||
depends_on:
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:80/']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- placebo-network
|
||||
|
||||
# PWA (Progressive Web App)
|
||||
pwa:
|
||||
build:
|
||||
context: ./pwa
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_URL: ${VITE_API_URL:-http://localhost:3000/api/v1}
|
||||
VITE_CMS_URL: ${VITE_CMS_URL:-http://localhost:1337}
|
||||
container_name: placebo-pwa
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '5174:80'
|
||||
depends_on:
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:80/']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- placebo-network
|
||||
|
||||
# CMS (Strapi)
|
||||
cms:
|
||||
build:
|
||||
context: ./cms/cms
|
||||
dockerfile: Dockerfile
|
||||
container_name: placebo-cms
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_CLIENT: postgres
|
||||
DATABASE_HOST: postgres
|
||||
DATABASE_PORT: 5432
|
||||
DATABASE_NAME: placebo_cms_db
|
||||
DATABASE_USERNAME: placebo_user
|
||||
DATABASE_PASSWORD: ${POSTGRES_PASSWORD:-placebo_password}
|
||||
DATABASE_SSL: 'false'
|
||||
HOST: 0.0.0.0
|
||||
PORT: 1337
|
||||
APP_KEYS: ${STRAPI_APP_KEYS:-key1,key2,key3,key4}
|
||||
API_TOKEN_SALT: ${STRAPI_API_TOKEN_SALT:-change-me}
|
||||
ADMIN_JWT_SECRET: ${STRAPI_ADMIN_JWT_SECRET:-change-me}
|
||||
TRANSFER_TOKEN_SALT: ${STRAPI_TRANSFER_TOKEN_SALT:-change-me}
|
||||
JWT_SECRET: ${STRAPI_JWT_SECRET:-change-me}
|
||||
ports:
|
||||
- '1337:1337'
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- cms_uploads:/app/public/uploads
|
||||
healthcheck:
|
||||
test: ['CMD', 'node', '-e', "require('http').get('http://localhost:1337/_health', (r) => {if(r.statusCode !== 200) process.exit(1)})"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
networks:
|
||||
- placebo-network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
cms_uploads:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
placebo-network:
|
||||
driver: bridge
|
||||
149
docker-compose.yml
Normal file
149
docker-compose.yml
Normal file
@ -0,0 +1,149 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PostgreSQL database for production
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: placebo-postgres
|
||||
environment:
|
||||
POSTGRES_DB: placebo_backend_db
|
||||
POSTGRES_USER: placebo_user
|
||||
POSTGRES_PASSWORD: placebo_password
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U placebo_user -d placebo_backend_db"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- placebo-network
|
||||
|
||||
# Backend API (NestJS)
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: placebo-backend
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_TYPE: postgres
|
||||
DATABASE_HOST: postgres
|
||||
DATABASE_PORT: 5432
|
||||
DATABASE_USERNAME: placebo_user
|
||||
DATABASE_PASSWORD: placebo_password
|
||||
DATABASE_NAME: placebo_backend_db
|
||||
DATABASE_SYNCHRONIZE: "false"
|
||||
DATABASE_LOGGING: "false"
|
||||
CORS_ORIGIN: http://localhost:5173,http://localhost:3001
|
||||
PORT: 3000
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./backend/.env:/app/.env:ro
|
||||
- ./backend/uploads:/app/uploads
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', (r) => {if(r.statusCode !== 200) throw new Error()})"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- placebo-network
|
||||
|
||||
# Frontend (TanStack React)
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: placebo-frontend
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
VITE_API_URL: http://localhost:3000
|
||||
VITE_CMS_URL: http://localhost:1337
|
||||
ports:
|
||||
- "3001:80"
|
||||
depends_on:
|
||||
- backend
|
||||
volumes:
|
||||
- ./frontend/.env:/app/.env:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- placebo-network
|
||||
|
||||
# CMS (Strapi)
|
||||
cms:
|
||||
build:
|
||||
context: ./cms/cms
|
||||
dockerfile: Dockerfile
|
||||
container_name: placebo-cms
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_CLIENT: postgres
|
||||
DATABASE_HOST: postgres
|
||||
DATABASE_PORT: 5432
|
||||
DATABASE_NAME: placebo_db
|
||||
DATABASE_USERNAME: placebo_user
|
||||
DATABASE_PASSWORD: placebo_password
|
||||
DATABASE_SSL: "false"
|
||||
JWT_SECRET: ${JWT_SECRET:-your-jwt-secret-key-change-in-production}
|
||||
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET:-your-admin-jwt-secret-key-change-in-production}
|
||||
APP_KEYS: ${APP_KEYS:-your-app-keys-change-in-production}
|
||||
API_TOKEN_SALT: ${API_TOKEN_SALT:-your-api-token-salt-change-in-production}
|
||||
TRANSFER_TOKEN_SALT: ${TRANSFER_TOKEN_SALT:-your-transfer-token-salt-change-in-production}
|
||||
CORS_ORIGIN: http://localhost:5173,http://localhost:3001
|
||||
PORT: 1337
|
||||
ports:
|
||||
- "1337:1337"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./cms/cms/.env:/app/.env:ro
|
||||
- ./cms/cms/public/uploads:/app/public/uploads
|
||||
- ./cms/cms/.tmp:/app/.tmp
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:1337/_health', (r) => {if(r.statusCode !== 200) throw new Error()})"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
networks:
|
||||
- placebo-network
|
||||
|
||||
# Nginx reverse proxy (for production)
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: placebo-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||
- ./frontend/dist:/usr/share/nginx/html:ro
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
- cms
|
||||
networks:
|
||||
- placebo-network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
placebo-network:
|
||||
driver: bridge
|
||||
@ -0,0 +1,61 @@
|
||||
---
|
||||
name: integration-react-tanstack-router-code-based
|
||||
description: >-
|
||||
PostHog integration for React applications using TanStack Router with
|
||||
code-based routing
|
||||
metadata:
|
||||
author: PostHog
|
||||
version: 1.8.1
|
||||
---
|
||||
|
||||
# PostHog integration for React with TanStack Router (code-based)
|
||||
|
||||
This skill helps you add PostHog analytics to React with TanStack Router (code-based) applications.
|
||||
|
||||
## Workflow
|
||||
|
||||
Follow these steps in order to complete the integration:
|
||||
|
||||
1. `basic-integration-1.0-begin.md` - PostHog Setup - Begin ← **Start here**
|
||||
2. `basic-integration-1.1-edit.md` - PostHog Setup - Edit
|
||||
3. `basic-integration-1.2-revise.md` - PostHog Setup - Revise
|
||||
4. `basic-integration-1.3-conclude.md` - PostHog Setup - Conclusion
|
||||
|
||||
## Reference files
|
||||
|
||||
- `EXAMPLE.md` - React with TanStack Router (code-based) example project code
|
||||
- `tanstack-start.md` - Tanstack start - docs
|
||||
- `identify-users.md` - Identify users - docs
|
||||
- `basic-integration-1.0-begin.md` - PostHog setup - begin
|
||||
- `basic-integration-1.1-edit.md` - PostHog setup - edit
|
||||
- `basic-integration-1.2-revise.md` - PostHog setup - revise
|
||||
- `basic-integration-1.3-conclude.md` - PostHog setup - conclusion
|
||||
|
||||
The example project shows the target implementation pattern. Consult the documentation for API details.
|
||||
|
||||
## Key principles
|
||||
|
||||
- **Environment variables**: Always use environment variables for PostHog keys. Never hardcode them.
|
||||
- **Minimal changes**: Add PostHog code alongside existing integrations. Don't replace or restructure existing code.
|
||||
- **Match the example**: Your implementation should follow the example project's patterns as closely as possible.
|
||||
|
||||
## Framework guidelines
|
||||
|
||||
- For feature flags, use useFeatureFlagEnabled() or useFeatureFlagPayload() hooks - they handle loading states and external sync automatically
|
||||
- Add analytics capture in event handlers where user actions occur, NOT in useEffect reacting to state changes
|
||||
- Do NOT use useEffect for data transformation - calculate derived values during render instead
|
||||
- Do NOT use useEffect to respond to user events - put that logic in the event handler itself
|
||||
- Do NOT use useEffect to chain state updates - calculate all related updates together in the event handler
|
||||
- Do NOT use useEffect to notify parent components - call the parent callback alongside setState in the event handler
|
||||
- To reset component state when a prop changes, pass the prop as the component's key instead of using useEffect
|
||||
- useEffect is ONLY for synchronizing with external systems (non-React widgets, browser APIs, network subscriptions)
|
||||
- Use TanStack Router's built-in navigation events for pageview tracking instead of useEffect
|
||||
- Use PostHogProvider in the root component defined in either the file-based convention (__root.tsx) or code-based convention (wherever createRootRoute() is called) so all child routes have access to the PostHog client
|
||||
|
||||
## Identifying users
|
||||
|
||||
Identify users during login and signup events. Refer to the example code and documentation for the correct identify pattern for this framework. If both frontend and backend code exist, pass the client-side session and distinct ID using `X-POSTHOG-DISTINCT-ID` and `X-POSTHOG-SESSION-ID` headers to maintain correlation.
|
||||
|
||||
## Error tracking
|
||||
|
||||
Add PostHog error tracking to relevant files, particularly around critical user flows and API boundaries.
|
||||
@ -0,0 +1,783 @@
|
||||
# PostHog React with TanStack Router (code-based) Example Project
|
||||
|
||||
Repository: https://github.com/PostHog/context-mill
|
||||
Path: basics/react-tanstack-router-code-based
|
||||
|
||||
---
|
||||
|
||||
## README.md
|
||||
|
||||
# PostHog TanStack Router Example (Code-Based Routing)
|
||||
|
||||
This is a React and [TanStack Router](https://tanstack.com/router) example demonstrating PostHog integration with product analytics, session replay, and error tracking. This example uses **code-based routing** where routes are defined programmatically.
|
||||
|
||||
## Features
|
||||
|
||||
- **Product analytics**: Track user events and behaviors
|
||||
- **Session replay**: Record and replay user sessions
|
||||
- **Error tracking**: Capture and track errors
|
||||
- **User authentication**: Demo login system with PostHog user identification
|
||||
- **Client-side tracking**: Pure client-side React implementation
|
||||
- **Reverse proxy**: PostHog ingestion through Vite proxy
|
||||
|
||||
## Getting started
|
||||
|
||||
### 1. Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Configure environment variables
|
||||
|
||||
Create a `.env` file in the root directory:
|
||||
|
||||
```bash
|
||||
VITE_PUBLIC_POSTHOG_KEY=your_posthog_project_api_key
|
||||
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||
```
|
||||
|
||||
Get your PostHog API key from your [PostHog project settings](https://app.posthog.com/project/settings).
|
||||
|
||||
### 3. Run the development server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the app.
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── contexts/
|
||||
│ └── AuthContext.tsx # Authentication context with PostHog integration
|
||||
├── main.tsx # App entry point with all routes defined in code
|
||||
├── reportWebVitals.ts # Performance monitoring
|
||||
└── styles.css # Global styles
|
||||
```
|
||||
|
||||
## Key integration points
|
||||
|
||||
### PostHog provider setup (main.tsx)
|
||||
|
||||
PostHog is initialized using `PostHogProvider` from `@posthog/react`. The provider wraps the entire app in the root route component:
|
||||
|
||||
```typescript
|
||||
import { PostHogProvider } from '@posthog/react'
|
||||
import { createRootRoute } from '@tanstack/react-router'
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: RootComponent,
|
||||
})
|
||||
|
||||
function RootComponent() {
|
||||
return (
|
||||
<PostHogProvider
|
||||
apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_KEY!}
|
||||
options={{
|
||||
api_host: '/ingest',
|
||||
ui_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST || 'https://us.posthog.com',
|
||||
defaults: '2026-01-30',
|
||||
capture_exceptions: true,
|
||||
debug: import.meta.env.DEV,
|
||||
}}
|
||||
>
|
||||
{/* your app */}
|
||||
</PostHogProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### User identification (contexts/AuthContext.tsx)
|
||||
|
||||
```typescript
|
||||
import { usePostHog } from '@posthog/react'
|
||||
|
||||
const posthog = usePostHog()
|
||||
|
||||
posthog.identify(username, {
|
||||
username: username,
|
||||
})
|
||||
```
|
||||
|
||||
### Event tracking (main.tsx - BurritoPage)
|
||||
|
||||
```typescript
|
||||
import { usePostHog } from '@posthog/react'
|
||||
|
||||
const posthog = usePostHog()
|
||||
|
||||
posthog.capture('burrito_considered', {
|
||||
total_considerations: count,
|
||||
username: username,
|
||||
})
|
||||
```
|
||||
|
||||
### Error tracking (main.tsx - ProfilePage)
|
||||
|
||||
```typescript
|
||||
posthog.captureException(error)
|
||||
```
|
||||
|
||||
## TanStack Router details
|
||||
|
||||
This example uses TanStack Router with **code-based routing**. Key details:
|
||||
|
||||
1. **Client-side only**: No server-side logic, no API routes, no posthog-node
|
||||
2. **Code-based routing**: All routes defined in `main.tsx` using `createRoute()` and `createRootRoute()`
|
||||
3. **Manual route tree**: Routes connected with `addChildren()` method
|
||||
4. **Standard hooks**: Uses `useNavigate()` from @tanstack/react-router
|
||||
5. **Vite proxy**: Uses Vite's proxy config for PostHog calls
|
||||
6. **Environment variables**: Uses `import.meta.env.VITE_*`
|
||||
7. **PostHog provider**: Uses `PostHogProvider` from `@posthog/react` in root route
|
||||
|
||||
### Code-based vs File-based routing
|
||||
|
||||
This example demonstrates **code-based routing**, where routes are defined programmatically:
|
||||
|
||||
```typescript
|
||||
import { createRoute, createRootRoute, createRouter } from '@tanstack/react-router'
|
||||
|
||||
const rootRoute = createRootRoute({ component: RootComponent })
|
||||
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: Home,
|
||||
})
|
||||
|
||||
const burritoRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/burrito',
|
||||
component: BurritoPage,
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([indexRoute, burritoRoute])
|
||||
|
||||
const router = createRouter({ routeTree })
|
||||
```
|
||||
|
||||
For file-based routing (auto-generated from file structure), see the `react-tanstack-router-file-based` example.
|
||||
|
||||
## Learn more
|
||||
|
||||
- [PostHog Documentation](https://posthog.com/docs)
|
||||
- [TanStack Router Documentation](https://tanstack.com/router)
|
||||
- [TanStack Router Code-Based Routing](https://tanstack.com/router/latest/docs/framework/react/guide/code-based-routing)
|
||||
- [PostHog React Integration Guide](https://posthog.com/docs/libraries/react)
|
||||
|
||||
---
|
||||
|
||||
## .env.example
|
||||
|
||||
```example
|
||||
VITE_PUBLIC_POSTHOG_KEY=<ph_project_api_key>
|
||||
VITE_PUBLIC_POSTHOG_HOST=<ph_client_api_host>
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## .prettierignore
|
||||
|
||||
```
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## index.html
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="React TanStack Router code-based routing example"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>React TanStack Router - Code-Based</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## prettier.config.js
|
||||
|
||||
```js
|
||||
// @ts-check
|
||||
|
||||
/** @type {import('prettier').Config} */
|
||||
const config = {
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
trailingComma: "all",
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## public/robots.txt
|
||||
|
||||
```txt
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## src/contexts/AuthContext.tsx
|
||||
|
||||
```tsx
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react';
|
||||
import { usePostHog } from '@posthog/react';
|
||||
|
||||
interface User {
|
||||
username: string;
|
||||
burritoConsiderations: number;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
login: (username: string, password: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
incrementBurritoConsiderations: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
const users: Map<string, User> = new Map();
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// Use lazy initializer to read from localStorage only once on mount
|
||||
const [user, setUser] = useState<User | null>(() => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const storedUsername = localStorage.getItem('currentUser');
|
||||
if (storedUsername) {
|
||||
const existingUser = users.get(storedUsername);
|
||||
if (existingUser) {
|
||||
return existingUser;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const posthog = usePostHog();
|
||||
|
||||
const login = async (username: string, password: string): Promise<boolean> => {
|
||||
if (!username || !password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get or create user in local map
|
||||
let user = users.get(username);
|
||||
const isNewUser = !user;
|
||||
|
||||
if (!user) {
|
||||
user = { username, burritoConsiderations: 0 };
|
||||
users.set(username, user);
|
||||
}
|
||||
|
||||
setUser(user);
|
||||
localStorage.setItem('currentUser', username);
|
||||
|
||||
// Identify user in PostHog using username as distinct ID
|
||||
posthog.identify(username, {
|
||||
username: username,
|
||||
isNewUser: isNewUser,
|
||||
});
|
||||
|
||||
// Capture login event
|
||||
posthog.capture('user_logged_in', {
|
||||
username: username,
|
||||
isNewUser: isNewUser,
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
// Capture logout event before resetting
|
||||
posthog.capture('user_logged_out');
|
||||
posthog.reset();
|
||||
|
||||
setUser(null);
|
||||
localStorage.removeItem('currentUser');
|
||||
};
|
||||
|
||||
const incrementBurritoConsiderations = () => {
|
||||
if (user) {
|
||||
user.burritoConsiderations++;
|
||||
users.set(user.username, user);
|
||||
setUser({ ...user });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout, incrementBurritoConsiderations }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## src/main.tsx
|
||||
|
||||
```tsx
|
||||
import { StrictMode, useState } from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import {
|
||||
Link,
|
||||
Outlet,
|
||||
RouterProvider,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
useNavigate,
|
||||
} from '@tanstack/react-router'
|
||||
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
||||
import { TanStackDevtools } from '@tanstack/react-devtools'
|
||||
import { PostHogProvider, usePostHog } from '@posthog/react'
|
||||
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import './styles.css'
|
||||
import reportWebVitals from './reportWebVitals'
|
||||
|
||||
// ============================================================================
|
||||
// Root Route
|
||||
// ============================================================================
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: RootComponent,
|
||||
})
|
||||
|
||||
function RootComponent() {
|
||||
return (
|
||||
<PostHogProvider
|
||||
apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_KEY!}
|
||||
options={{
|
||||
api_host: '/ingest',
|
||||
ui_host:
|
||||
import.meta.env.VITE_PUBLIC_POSTHOG_HOST || 'https://us.posthog.com',
|
||||
defaults: '2026-01-30',
|
||||
capture_exceptions: true,
|
||||
debug: import.meta.env.DEV,
|
||||
}}
|
||||
>
|
||||
<AuthProvider>
|
||||
<Header />
|
||||
<main>
|
||||
<Outlet />
|
||||
</main>
|
||||
<TanStackDevtools
|
||||
config={{
|
||||
position: 'bottom-right',
|
||||
}}
|
||||
plugins={[
|
||||
{
|
||||
name: 'Tanstack Router',
|
||||
render: <TanStackRouterDevtoolsPanel />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</AuthProvider>
|
||||
</PostHogProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Header Component
|
||||
// ============================================================================
|
||||
|
||||
function Header() {
|
||||
const { user, logout } = useAuth()
|
||||
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="header-container">
|
||||
<nav>
|
||||
<Link to="/">Home</Link>
|
||||
{user && (
|
||||
<>
|
||||
<Link to="/burrito">Burrito Consideration</Link>
|
||||
<Link to="/profile">Profile</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<div className="user-section">
|
||||
{user ? (
|
||||
<>
|
||||
<span>Welcome, {user.username}!</span>
|
||||
<button onClick={logout} className="btn-logout">
|
||||
Logout
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span>Not logged in</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Index Route (Home Page)
|
||||
// ============================================================================
|
||||
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: Home,
|
||||
})
|
||||
|
||||
function Home() {
|
||||
const { user, login } = useAuth()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const success = await login(username, password)
|
||||
if (success) {
|
||||
setUsername('')
|
||||
setPassword('')
|
||||
} else {
|
||||
setError('Please provide both username and password')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Login failed:', err)
|
||||
setError('An error occurred during login')
|
||||
}
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<div className="container">
|
||||
<h1>Welcome back, {user.username}!</h1>
|
||||
<p>You are now logged in. Feel free to explore:</p>
|
||||
<ul>
|
||||
<li>Consider the potential of burritos</li>
|
||||
<li>View your profile and statistics</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h1>Welcome to Burrito Consideration App</h1>
|
||||
<p>Please sign in to begin your burrito journey</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">Username:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter any username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Password:</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter any password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
<button type="submit" className="btn-primary">
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="note">
|
||||
Note: This is a demo app. Use any username and password to sign in.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Burrito Route
|
||||
// ============================================================================
|
||||
|
||||
const burritoRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/burrito',
|
||||
component: BurritoPage,
|
||||
})
|
||||
|
||||
function BurritoPage() {
|
||||
const { user, incrementBurritoConsiderations } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const posthog = usePostHog()
|
||||
const [hasConsidered, setHasConsidered] = useState(false)
|
||||
|
||||
// Redirect to home if not logged in
|
||||
if (!user) {
|
||||
navigate({ to: '/' })
|
||||
return null
|
||||
}
|
||||
|
||||
const handleConsideration = () => {
|
||||
incrementBurritoConsiderations()
|
||||
setHasConsidered(true)
|
||||
setTimeout(() => setHasConsidered(false), 2000)
|
||||
|
||||
// Capture burrito consideration event
|
||||
console.log('posthog', posthog)
|
||||
posthog.capture('burrito_considered', {
|
||||
total_considerations: user.burritoConsiderations + 1,
|
||||
username: user.username,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h1>Burrito consideration zone</h1>
|
||||
<p>Take a moment to truly consider the potential of burritos.</p>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<button onClick={handleConsideration} className="btn-burrito">
|
||||
I have considered the burrito potential
|
||||
</button>
|
||||
|
||||
{hasConsidered && (
|
||||
<p className="success">
|
||||
Thank you for your consideration! Count: {user.burritoConsiderations}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="stats">
|
||||
<h3>Consideration stats</h3>
|
||||
<p>Total considerations: {user.burritoConsiderations}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Profile Route
|
||||
// ============================================================================
|
||||
|
||||
const profileRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/profile',
|
||||
component: ProfilePage,
|
||||
})
|
||||
|
||||
function ProfilePage() {
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const posthog = usePostHog()
|
||||
|
||||
// Redirect to home if not logged in
|
||||
if (!user) {
|
||||
navigate({ to: '/' })
|
||||
return null
|
||||
}
|
||||
|
||||
const triggerTestError = () => {
|
||||
try {
|
||||
throw new Error('Test error for PostHog error tracking')
|
||||
} catch (err) {
|
||||
posthog.captureException(err)
|
||||
console.error('Captured error:', err)
|
||||
alert('Error captured and sent to PostHog!')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h1>User Profile</h1>
|
||||
|
||||
<div className="stats">
|
||||
<h2>Your Information</h2>
|
||||
<p>
|
||||
<strong>Username:</strong> {user.username}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Burrito Considerations:</strong> {user.burritoConsiderations}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '2rem' }}>
|
||||
<button
|
||||
onClick={triggerTestError}
|
||||
className="btn-primary"
|
||||
style={{ backgroundColor: '#dc3545' }}
|
||||
>
|
||||
Trigger Test Error (for PostHog)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '2rem' }}>
|
||||
<h3>Your Burrito Journey</h3>
|
||||
{user.burritoConsiderations === 0 ? (
|
||||
<p>
|
||||
You haven't considered any burritos yet. Visit the Burrito
|
||||
Consideration page to start!
|
||||
</p>
|
||||
) : user.burritoConsiderations === 1 ? (
|
||||
<p>You've considered the burrito potential once. Keep going!</p>
|
||||
) : user.burritoConsiderations < 5 ? (
|
||||
<p>You're getting the hang of burrito consideration!</p>
|
||||
) : user.burritoConsiderations < 10 ? (
|
||||
<p>You're becoming a burrito consideration expert!</p>
|
||||
) : (
|
||||
<p>You are a true burrito consideration master!</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Route Tree & Router Setup
|
||||
// ============================================================================
|
||||
|
||||
const routeTree = rootRoute.addChildren([indexRoute, burritoRoute, profileRoute])
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: {},
|
||||
defaultPreload: 'intent',
|
||||
scrollRestoration: true,
|
||||
defaultStructuralSharing: true,
|
||||
defaultPreloadStaleTime: 0,
|
||||
})
|
||||
|
||||
// Register the router instance for type safety
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Render the App
|
||||
// ============================================================================
|
||||
|
||||
const rootElement = document.getElementById('app')
|
||||
if (rootElement && !rootElement.innerHTML) {
|
||||
const root = ReactDOM.createRoot(rootElement)
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals()
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## src/reportWebVitals.ts
|
||||
|
||||
```ts
|
||||
const reportWebVitals = (onPerfEntry?: () => void) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ onCLS, onINP, onFCP, onLCP, onTTFB }) => {
|
||||
onCLS(onPerfEntry)
|
||||
onINP(onPerfEntry)
|
||||
onFCP(onPerfEntry)
|
||||
onLCP(onPerfEntry)
|
||||
onTTFB(onPerfEntry)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default reportWebVitals
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## vite.config.ts
|
||||
|
||||
```ts
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import viteReact from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
return {
|
||||
plugins: [viteReact(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/ingest': {
|
||||
target: env.VITE_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/ingest/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
---
|
||||
title: PostHog Setup - Begin
|
||||
description: Start the event tracking setup process by analyzing the project and creating an event tracking plan
|
||||
---
|
||||
|
||||
We're making an event tracking plan for this project.
|
||||
|
||||
Before proceeding, find any existing `posthog.capture()` code. Make note of event name formatting.
|
||||
|
||||
From the project's file list, select between 10 and 15 files that might have interesting business value for event tracking, especially conversion and churn events. Also look for additional files related to login that could be used for identifying users, along with error handling. Read the files. If a file is already well-covered by PostHog events, replace it with another option. Do not spawn subagents.
|
||||
|
||||
Look for opportunities to track client-side events.
|
||||
|
||||
**IMPORTANT: Server-side events are REQUIRED** if the project includes any instrumentable server-side code. If the project has API routes (e.g., `app/api/**/route.ts`) or Server Actions, you MUST include server-side events for critical business operations like:
|
||||
|
||||
- Payment/checkout completion
|
||||
- Webhook handlers
|
||||
- Authentication endpoints
|
||||
|
||||
Do not skip server-side events - they capture actions that cannot be tracked client-side.
|
||||
|
||||
Create a new file with a JSON array at the root of the project: .posthog-events.json. It should include one object for each event we want to add: event name, event description, and the file path we want to place the event in. If events already exist, don't duplicate them; supplement them.
|
||||
|
||||
Track actions only, not pageviews. These can be captured automatically. Exceptions can be made for "viewed"-type events that correspond to the top of a conversion funnel.
|
||||
|
||||
As you review files, make an internal note of opportunities to identify users and catch errors. We'll need them for the next step.
|
||||
|
||||
## Status
|
||||
|
||||
Before beginning a phase of the setup, you will send a status message with the exact prefix '[STATUS]', as in:
|
||||
|
||||
[STATUS] Checking project structure.
|
||||
|
||||
Status to report in this phase:
|
||||
|
||||
- Checking project structure
|
||||
- Verifying PostHog dependencies
|
||||
- Generating events based on project
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Upon completion, continue with:** [basic-integration-1.1-edit.md](basic-integration-1.1-edit.md)
|
||||
@ -0,0 +1,37 @@
|
||||
---
|
||||
title: PostHog Setup - Edit
|
||||
description: Implement PostHog event tracking in the identified files, following best practices and the example project
|
||||
---
|
||||
|
||||
For each of the files and events noted in .posthog-events.json, make edits to capture events using PostHog. Make sure to set up any helper files needed. Carefully examine the included example project code: your implementation should match it as closely as possible. Do not spawn subagents.
|
||||
|
||||
Use environment variables for PostHog keys. Do not hardcode PostHog keys.
|
||||
|
||||
If a file already has existing integration code for other tools or services, don't overwrite or remove that code. Place PostHog code below it.
|
||||
|
||||
For each event, add useful properties, and use your access to the PostHog source code to ensure correctness. You also have access to documentation about creating new events with PostHog. Consider this documentation carefully and follow it closely before adding events. Your integration should be based on documented best practices. Carefully consider how the user project's framework version may impact the correct PostHog integration approach.
|
||||
|
||||
Remember that you can find the source code for any dependency in the node_modules directory. This may be necessary to properly populate property names. There are also example project code files available via the PostHog MCP; use these for reference.
|
||||
|
||||
Where possible, add calls for PostHog's identify() function on the client side upon events like logins and signups. Use the contents of login and signup forms to identify users on submit. If there is server-side code, pass the client-side session and distinct ID to the server-side code to identify the user. On the server side, make sure events have a matching distinct ID where relevant.
|
||||
|
||||
It's essential to do this in both client code and server code, so that user behavior from both domains is easy to correlate.
|
||||
|
||||
You should also add PostHog exception capture error tracking to these files where relevant.
|
||||
|
||||
Remember: Do not alter the fundamental architecture of existing files. Make your additions minimal and targeted.
|
||||
|
||||
Remember the documentation and example project resources you were provided at the beginning. Read them now.
|
||||
|
||||
## Status
|
||||
|
||||
Status to report in this phase:
|
||||
|
||||
- Inserting PostHog capture code
|
||||
- A status message for each file whose edits you are planning, including a high level summary of changes
|
||||
- A status message for each file you have edited
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Upon completion, continue with:** [basic-integration-1.2-revise.md](basic-integration-1.2-revise.md)
|
||||
@ -0,0 +1,22 @@
|
||||
---
|
||||
title: PostHog Setup - Revise
|
||||
description: Review and fix any errors in the PostHog integration implementation
|
||||
---
|
||||
|
||||
Check the project for errors. Read the package.json file for any type checking or build scripts that may provide input about what to fix. Remember that you can find the source code for any dependency in the node_modules directory. Do not spawn subagents.
|
||||
|
||||
Ensure that any components created were actually used.
|
||||
|
||||
Once all other tasks are complete, run any linter or prettier-like scripts found in the package.json, but ONLY on the files you have edited or created during this session. Do not run formatting or linting across the entire project's codebase.
|
||||
|
||||
## Status
|
||||
|
||||
Status to report in this phase:
|
||||
|
||||
- Finding and correcting errors
|
||||
- Report details of any errors you fix
|
||||
- Linting, building and prettying
|
||||
|
||||
---
|
||||
|
||||
**Upon completion, continue with:** [basic-integration-1.3-conclude.md](basic-integration-1.3-conclude.md)
|
||||
@ -0,0 +1,38 @@
|
||||
---
|
||||
title: PostHog Setup - Conclusion
|
||||
description: Review and fix any errors in the PostHog integration implementation
|
||||
---
|
||||
|
||||
Use the PostHog MCP to create a new dashboard named "Analytics basics" based on the events created here. Make sure to use the exact same event names as implemented in the code. Populate it with up to five insights, with special emphasis on things like conversion funnels, churn events, and other business critical insights.
|
||||
|
||||
Search for a file called `.posthog-events.json` and read it for available events. Do not spawn subagents.
|
||||
|
||||
Create the file posthog-setup-report.md. It should include a summary of the integration edits, a table with the event names, event descriptions, and files where events were added, along with a list of links for the dashboard and insights created. Follow this format:
|
||||
|
||||
<wizard-report>
|
||||
# PostHog post-wizard report
|
||||
|
||||
The wizard has completed a deep integration of your project. [Detailed summary of changes]
|
||||
|
||||
[table of events/descriptions/files]
|
||||
|
||||
## Next steps
|
||||
|
||||
We've built some insights and a dashboard for you to keep an eye on user behavior, based on the events we just instrumented:
|
||||
|
||||
[links]
|
||||
|
||||
### Agent skill
|
||||
|
||||
We've left an agent skill folder in your project. You can use this context for further agent development when using Claude Code. This will help ensure the model provides the most up-to-date approaches for integrating PostHog.
|
||||
|
||||
</wizard-report>
|
||||
|
||||
Upon completion, remove .posthog-events.json.
|
||||
|
||||
## Status
|
||||
|
||||
Status to report in this phase:
|
||||
|
||||
- Configured dashboard: [insert PostHog dashboard URL]
|
||||
- Created setup report: [insert full local file path]
|
||||
@ -0,0 +1,202 @@
|
||||
# Identify users - Docs
|
||||
|
||||
Linking events to specific users enables you to build a full picture of how they're using your product across different sessions, devices, and platforms.
|
||||
|
||||
This is straightforward to do when [capturing backend events](/docs/product-analytics/capture-events?tab=Node.js.md), as you associate events to a specific user using a `distinct_id`, which is a required argument.
|
||||
|
||||
However, in the frontend of a [web](/docs/libraries/js/features#capturing-events.md) or [mobile app](/docs/libraries/ios#capturing-events.md), a `distinct_id` is not a required argument — PostHog's SDKs will generate an anonymous `distinct_id` for you automatically and you can capture events anonymously, provided you use the appropriate [configuration](/docs/libraries/js/features#capturing-anonymous-events.md).
|
||||
|
||||
To link events to specific users, call `identify`:
|
||||
|
||||
PostHog AI
|
||||
|
||||
### Web
|
||||
|
||||
```javascript
|
||||
posthog.identify(
|
||||
'distinct_id', // Replace 'distinct_id' with your user's unique identifier
|
||||
{ email: 'max@hedgehogmail.com', name: 'Max Hedgehog' } // optional: set additional person properties
|
||||
);
|
||||
```
|
||||
|
||||
### Android
|
||||
|
||||
```kotlin
|
||||
PostHog.identify(
|
||||
distinctId = distinctID, // Replace 'distinctID' with your user's unique identifier
|
||||
// optional: set additional person properties
|
||||
userProperties = mapOf(
|
||||
"name" to "Max Hedgehog",
|
||||
"email" to "max@hedgehogmail.com"
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### iOS
|
||||
|
||||
```swift
|
||||
PostHogSDK.shared.identify("distinct_id", // Replace "distinct_id" with your user's unique identifier
|
||||
userProperties: ["name": "Max Hedgehog", "email": "max@hedgehogmail.com"]) // optional: set additional person properties
|
||||
```
|
||||
|
||||
### React Native
|
||||
|
||||
```jsx
|
||||
posthog.identify('distinct_id', { // Replace "distinct_id" with your user's unique identifier
|
||||
email: 'max@hedgehogmail.com', // optional: set additional person properties
|
||||
name: 'Max Hedgehog'
|
||||
})
|
||||
```
|
||||
|
||||
### Dart
|
||||
|
||||
```dart
|
||||
await Posthog().identify(
|
||||
userId: 'distinct_id', // Replace "distinct_id" with your user's unique identifier
|
||||
userProperties: {
|
||||
email: "max@hedgehogmail.com", // optional: set additional person properties
|
||||
name: "Max Hedgehog"
|
||||
});
|
||||
```
|
||||
|
||||
Events captured after calling `identify` are identified events and this creates a person profile if one doesn't exist already.
|
||||
|
||||
Due to the cost of processing them, anonymous events can be up to 4x cheaper than identified events, so it's recommended you only capture identified events when needed.
|
||||
|
||||
## How identify works
|
||||
|
||||
When a user starts browsing your website or app, PostHog automatically assigns them an **anonymous ID**, which is stored locally.
|
||||
|
||||
Provided you've [configured persistence](/docs/libraries/js/persistence.md) to use cookies or `localStorage`, this enables us to track anonymous users – even across different sessions.
|
||||
|
||||
By calling `identify` with a `distinct_id` of your choice (usually the user's ID in your database, or their email), you link the anonymous ID and distinct ID together.
|
||||
|
||||
Thus, all past and future events made with that anonymous ID are now associated with the distinct ID.
|
||||
|
||||
This enables you to do things like associate events with a user from before they log in for the first time, or associate their events across different devices or platforms.
|
||||
|
||||
Using identify in the backend
|
||||
|
||||
Although you can call `identify` using our backend SDKs, it is used most in frontends. This is because there is no concept of anonymous sessions in the backend SDKs, so calling `identify` only updates person profiles.
|
||||
|
||||
## Best practices when using `identify`
|
||||
|
||||
### 1\. Call `identify` as soon as you're able to
|
||||
|
||||
In your frontend, you should call `identify` as soon as you're able to.
|
||||
|
||||
Typically, this is every time your **app loads** for the first time, and directly after your **users log in**.
|
||||
|
||||
This ensures that events sent during your users' sessions are correctly associated with them.
|
||||
|
||||
You only need to call `identify` once per session, and you should avoid calling it multiple times unnecessarily.
|
||||
|
||||
If you call `identify` multiple times with the same data without reloading the page in between, PostHog will ignore the subsequent calls.
|
||||
|
||||
### 2\. Use unique strings for distinct IDs
|
||||
|
||||
If two users have the same distinct ID, their data is merged and they are considered one user in PostHog. Two common ways this can happen are:
|
||||
|
||||
- Your logic for generating IDs does not generate sufficiently strong IDs and you can end up with a clash where 2 users have the same ID.
|
||||
- There's a bug, typo, or mistake in your code leading to most or all users being identified with generic IDs like `null`, `true`, or `distinctId`.
|
||||
|
||||
PostHog also has built-in protections to stop the most common distinct ID mistakes.
|
||||
|
||||
### 3\. Reset after logout
|
||||
|
||||
If a user logs out on your frontend, you should call `reset()` to unlink any future events made on that device with that user.
|
||||
|
||||
This is important if your users are sharing a computer, as otherwise all of those users are grouped together into a single user due to shared cookies between sessions.
|
||||
|
||||
**We strongly recommend you call `reset` on logout even if you don't expect users to share a computer.**
|
||||
|
||||
You can do that like so:
|
||||
|
||||
PostHog AI
|
||||
|
||||
### Web
|
||||
|
||||
```javascript
|
||||
posthog.reset()
|
||||
```
|
||||
|
||||
### iOS
|
||||
|
||||
```swift
|
||||
PostHogSDK.shared.reset()
|
||||
```
|
||||
|
||||
### Android
|
||||
|
||||
```kotlin
|
||||
PostHog.reset()
|
||||
```
|
||||
|
||||
### React Native
|
||||
|
||||
```jsx
|
||||
posthog.reset()
|
||||
```
|
||||
|
||||
### Dart
|
||||
|
||||
```dart
|
||||
Posthog().reset()
|
||||
```
|
||||
|
||||
If you *also* want to reset the `device_id` so that the device will be considered a new device in future events, you can pass `true` as an argument:
|
||||
|
||||
Web
|
||||
|
||||
PostHog AI
|
||||
|
||||
```javascript
|
||||
posthog.reset(true)
|
||||
```
|
||||
|
||||
### 4\. Person profiles and properties
|
||||
|
||||
You'll notice that one of the parameters in the `identify` method is a `properties` object.
|
||||
|
||||
This enables you to set [person properties](/docs/product-analytics/person-properties.md).
|
||||
|
||||
Whenever possible, we recommend passing in all person properties you have available each time you call identify, as this ensures their person profile on PostHog is up to date.
|
||||
|
||||
Person properties can also be set being adding a `$set` property to a event `capture` call.
|
||||
|
||||
See our [person properties docs](/docs/product-analytics/person-properties.md) for more details on how to work with them and best practices.
|
||||
|
||||
### 5\. Use deep links between platforms
|
||||
|
||||
We recommend you call `identify` [as soon as you're able](#1-call-identify-as-soon-as-youre-able), typically when a user signs up or logs in.
|
||||
|
||||
This doesn't work if one or both platforms are unauthenticated. Some examples of such cases are:
|
||||
|
||||
- Onboarding and signup flows before authentication.
|
||||
- Unauthenticated web pages redirecting to authenticated mobile apps.
|
||||
- Authenticated web apps prompting an app download.
|
||||
|
||||
In these cases, you can use a [deep link](https://developer.android.com/training/app-links/deep-linking) on Android and [universal links](https://developer.apple.com/documentation/xcode/supporting-universal-links-in-your-app) on iOS to identify users.
|
||||
|
||||
1. Use `posthog.get_distinct_id()` to get the current distinct ID. Even if you cannot call identify because the user is unauthenticated, this will return an anonymous distinct ID generated by PostHog.
|
||||
2. Add the distinct ID to the deep link as query parameters, along with other properties like UTM parameters.
|
||||
3. When the user is redirected to the app, parse the deep link and handle the following cases:
|
||||
|
||||
- The user is already authenticated on the mobile app. In this case, call [`posthog.alias()`](/docs/libraries/js/features#alias.md) with the distinct ID from the web. This associates the two distinct IDs as a single person.
|
||||
- The user is unauthenticated. In this case, call [`posthog.identify()`](/docs/libraries/js/features#identifying-users.md) with the distinct ID from the web. Events will be associated with this distinct ID.
|
||||
|
||||
As long as you associate the distinct IDs with `posthog.identify()` or `posthog.alias()`, you can track events generated across platforms.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Identifying users docs](/docs/product-analytics/identify.md)
|
||||
- [How person processing works](/docs/how-posthog-works/ingestion-pipeline#2-person-processing.md)
|
||||
- [An introductory guide to identifying users in PostHog](/tutorials/identifying-users-guide.md)
|
||||
|
||||
### Community questions
|
||||
|
||||
Ask a question
|
||||
|
||||
### Was this page useful?
|
||||
|
||||
HelpfulCould be better
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user