Compare commits

...

131 Commits

Author SHA1 Message Date
8026a25278 pwa fix
axios
2026-03-18 03:08:38 +01:00
e43680f798 cors 2026-03-09 11:17:44 +01:00
fe534a7875 text formating 2026-03-08 21:53:27 +01:00
6241c25af0 shares updated 2026-03-06 13:22:07 +01:00
a11194831d logs removed from api.ts 2026-03-02 23:49:48 +01:00
325fe9735b fix: add Traefik health check and pass host header for CMS service 2026-03-02 22:54:17 +01:00
a79a3af3d5 fix: restrict PostHog debug logging to development only 2026-03-02 22:29:14 +01:00
c0eea843bf translation 2026-03-02 22:22:06 +01:00
a59bdc7711 typos 2026-03-02 18:16:01 +01:00
b743608742 fix: update CSP to allow PostHog analytics scripts and connections
- Add PostHog domains to script-src and connect-src CSP directives
- Updated both development (index.html) and production (nginx.conf) CSP
- Allows https://eu.i.posthog.com and https://eu-assets.i.posthog.com
2026-03-01 02:14:49 +01:00
d0428aff0c fix: use correct PostHog API host from environment variable
Changed from hardcoded '/ingest' to VITE_PUBLIC_POSTHOG_HOST env var to ensure PostHog events are sent to the correct EU endpoint.
2026-03-01 02:09:21 +01:00
dea569df99 chore: update package-lock.json after clean install 2026-03-01 01:58:46 +01:00
91b256a45a up 2026-03-01 01:49:11 +01:00
2b6eef6509 fix: update react-dom to 19.2.4 to match react version
Resolves incompatible React versions error between react and react-dom packages.
2026-03-01 01:44:25 +01:00
fbb4d29e5d fix: add PostHog env vars to Dockerfiles for production builds 2026-03-01 01:42:09 +01:00
01a3c1a776 posthog 2026-03-01 01:36:11 +01:00
e218ad57e8 debug: add PostHog config logging 2026-03-01 01:23:55 +01:00
b9a4a45781 feat: integrate PostHog analytics for frontend and PWA
- Install posthog-js and @posthog/react packages
- Add PostHogProvider wrapper in main.tsx for both apps
- Configure PostHog with EU instance and 2026-01-30 defaults
- Add environment variables for API key and host
- Update docker-compose.coolify.yml with PostHog build args
2026-03-01 01:04:17 +01:00
64588935ca fix: update PWA with Google Fonts and CSP for production API connections 2026-03-01 00:27:57 +01:00
bba5a018ab fix: implement article view count tracking
- Add view count increment in findOne() and findBySlug() methods
- Create findOneWithoutIncrement() for internal operations
- Update remove(), archive(), and publish() to use findOneWithoutIncrement()
- Prevents view count inflation from admin operations
- Matches live blog view tracking implementation
2026-03-01 00:20:46 +01:00
aa43f50a8c fix: localize UI text to Macedonian and fix view count display
- Change date format from 'short' to 'long' month names in Macedonian
- Translate 'views' to 'прегледи' across all components
- Translate 'shares' to 'споделувања'
- Translate 'updates' to 'ажурирања'
- Translate 'By' to 'Од' for author attribution
- Translate 'Back to articles' to 'Назад кон вести'
- Translate archive page headers to Macedonian
- Translate auto-scroll button text to Macedonian
- Translate connection status to Macedonian
- Add fallback to 0 for undefined view counts (|| 0)
2026-02-28 23:57:33 +01:00
46bbeed525 hero fix 2026-02-28 23:38:11 +01:00
7258f059ce fix: remove duplicate /api/v1 in article detail route loader 2026-02-28 23:37:12 +01:00
a66db56156 fix: resolve API URL duplication, CORS issues, and image localhost URLs
- Fix duplicate /api/v1 in analytics.ts API calls
- Add STRAPI_PUBLIC_URL env var for public CMS access
- Update strapi.service to use public URL for images instead of localhost
- Images now use https://cms.placebo.mk instead of http://localhost:1337
2026-02-28 23:29:54 +01:00
ebd1fc28d5 fix: remove Traefik buffering middleware causing CMS timeout 2026-02-28 23:22:07 +01:00
cba9908180 fix: move Google Fonts to HTML head to resolve CSS @import order warning 2026-02-28 23:06:15 +01:00
287dcb4070 fav 2026-02-28 22:46:42 +01:00
50ac4531d3 typo fix 2026-02-28 22:38:23 +01:00
79bd134977 fix: correct CSS @import order to resolve 504 gateway timeout 2026-02-28 22:14:53 +01:00
88dbd2719d typo 2026-02-28 22:06:15 +01:00
e71dc2b90f foot 2026-02-28 21:38:07 +01:00
caf56d9fcd fix me 2026-02-28 20:32:10 +01:00
86c1c2d366 upatstvo za upotreba 2026-02-28 20:22:35 +01:00
4f2837ad4e feat: add About/Instructions page with comprehensive usage guide 2026-02-28 20:08:25 +01:00
c6427304ac feat: show live blog updates section open by default on homepage 2026-02-28 20:02:52 +01:00
515c4cacf3 feat: update live blog UI with chat layout, fix page title and favicon 2026-02-28 19:48:56 +01:00
59891b03dc fix: resolve file permissions issue for Strapi media uploads 2026-02-28 19:22:31 +01:00
8f68dab53f fix: increase file upload limits for Strapi media library to 200MB 2026-02-28 19:07:02 +01:00
fa7dcc2b08 feat: add general category, fix header wrapping, show last 5 live blog updates 2026-02-28 18:47:11 +01:00
3e61fe5694 fix: ensure slug and excerpt are never null to pass validation
- Extract slug generation to separate variable before using in DTO
- Convert null description to empty string for excerpt field
- Prevents class-validator from rejecting null values
- Fixes 'slug must be a string type' validation errors
2026-02-28 18:22:48 +01:00
a5008a3646 fix: add slug auto-generation fallback for articles without slugs
- Add generateSlug() helper to create URL-friendly slugs from titles
- Apply slug fallback in syncArticles() and syncSingleArticle()
- Handles articles created before slug field was added to schema
- Prevents 'slug cannot be null' database constraint errors
2026-02-28 18:02:33 +01:00
cbdb801655 fix: disable Authorization header for Strapi public API access
- Modify getHeaders() to always return empty object
- Prevents sending invalid Bearer token that causes 401 errors
- Strapi Public role permissions configured for unauthenticated access
- Will allow proper article sync from Strapi to backend
2026-02-28 17:46:59 +01:00
a5c57b33f7 fix: remove STRAPI_API_TOKEN to allow public API access
- Remove STRAPI_API_TOKEN environment variable from backend service
- Backend will now make requests to Strapi without Authorization header
- Allows Strapi Public role permissions to work correctly
- Fixes 401 Unauthorized error when syncing articles
2026-02-28 17:03:06 +01:00
b39972102a fix: use APP_GUARD provider for global authentication guard
- Replace manual guard instantiation with DI-managed APP_GUARD provider
- Fixes request timeout issue caused by improper Passport JWT strategy initialization
- Add @Public() decorator to remaining sync endpoints for public access
- Remove unused Reflector import from main.ts
2026-02-28 16:49:24 +01:00
8fbde18d02 feat: add GET endpoint for sync/all for easier testing 2026-02-28 16:33:27 +01:00
94b0239a0a fix: re-enable DATABASE_SYNCHRONIZE to create missing tables 2026-02-28 16:25:16 +01:00
ac610e6f6a fix: add missing slug and description fields to Article schema and fix auth header 2026-02-28 16:10:48 +01:00
8eaaf4afad fix: convert Article API files from TypeScript to JavaScript for Strapi compatibility 2026-02-28 15:48:47 +01:00
33008b64ce fix: enhance CORS configuration with explicit methods and headers 2026-02-28 15:33:45 +01:00
005d368dc7 debug: add logging to check if article content type loads 2026-02-28 15:29:41 +01:00
a8c0ab8884 feat: add Article content type schema and API endpoints 2026-02-27 20:42:53 +01:00
e443ece848 fix: remove article API files to allow Strapi UI to recreate them 2026-02-27 20:32:22 +01:00
76963f6eea aa 2026-02-27 20:10:49 +01:00
bfc7e76f17 fix: disable database sync and simplify backend startup 2026-02-27 20:10:03 +01:00
06a0c9fe05 fix: temporarily disable lifecycle hooks and CMS dependency to fix startup
- Disable lifecycle hooks (rename to .disabled) to prevent CMS crash
- Remove backend dependency on CMS to allow independent startup
- This should fix the Gateway Timeout on both services
2026-02-27 18:25:22 +01:00
c1b49a4cb6 fix: use internal Docker network for backend-to-CMS communication
- Changed STRAPI_URL from https://cms.placebo.mk to http://cms:1337
- Added cms dependency to backend service
- This fixes Gateway Timeout errors when backend tries to fetch from Strapi
- Enables proper container-to-container communication
2026-02-27 17:47:42 +01:00
4f30542014 feat: implement automatic webhook via Strapi lifecycle hooks 2026-02-27 17:11:04 +01:00
d7281024bf fix: configure Strapi public URL to prevent Gateway Timeout on publish 2026-02-27 16:18:24 +01:00
4a22e8a18a feat: auto-seed admin user on backend startup 2026-02-27 16:06:08 +01:00
994becc687 fix: copy src directory to include content type schemas in production 2026-02-24 21:01:13 +01:00
69e758b841 fix: explicitly link Traefik routers to services to resolve Gateway Timeout 2026-02-24 19:59:15 +01:00
17c6a1593c feat: enable database synchronization to initialize backend schema 2026-02-24 19:40:56 +01:00
d573846e5c fix: configure CORS to allow frontend and PWA domains
- Update backend main.ts to include www.placebo.mk in allowed origins
- Add logging to show which origins are allowed
- Set FRONTEND_URL, PWA_URL, STRAPI_URL env vars in docker-compose
- This fixes 'No Access-Control-Allow-Origin header' CORS errors
2026-02-24 19:28:54 +01:00
c3b01de12e fix: update CSP headers to allow production API domains
- Add https://api.placebo.mk to connect-src for API requests
- Add https://cms.placebo.mk to connect-src for CMS API
- Add Google Fonts domains to style-src and font-src
- Add manifest-src for PWA manifest
- This fixes 'Refused to connect' CSP violations
2026-02-24 19:16:22 +01:00
80a2ee89a6 fix: create symlink for Strapi admin build location
Strapi 5 looks for the admin build at
/app/node_modules/@strapi/admin/dist/server/server/build
but we have it at /app/dist/build. Create a symlink to fix this.
2026-02-24 19:02:05 +01:00
ff0916b37c fix: copy .strapi metadata directory for admin panel
- Add .strapi directory copy to Dockerfile
- Add admin path configuration
- This should fix the admin panel serving from correct location
2026-02-24 18:42:51 +01:00
6eb68b7bd3 debug: add detailed connection config logging
- Log the actual connection config being passed to Knex
- Show password length to verify it's set
- This should help identify why PostgreSQL sees 'root' user
2026-02-24 18:33:58 +01:00
fcbd082f6d feat: add detailed database configuration logging
- Log whether DATABASE_PASSWORD is set (without revealing value)
- Add timeout and debug options
- Restore correct nested connection structure for Strapi 5
- Add more diagnostic logging to troubleshoot connection issues
2026-02-24 18:26:25 +01:00
183636bceb fix: improve PostgreSQL health checks and expose port 5432
- Add explicit expose directive for port 5432 on both postgres containers
- Improve health check to test specific database and user
- Should help with database connection issues
2026-02-24 18:13:10 +01:00
5262e73a12 cms fix 2026-02-24 18:02:03 +01:00
590a96e502 fix: correct Strapi database configuration structure
The database config had an incorrect nested structure with
connection.client.connection which caused Strapi to fail with
'Cannot destructure property client of db.config.connection'.

Fixed to use the correct flat structure:
connection.client, connection.host, etc.
2026-02-24 18:01:30 +01:00
0ef26ba2f1 fix: add HTTP to HTTPS redirect and fix CMS Dockerfile
- Add HTTP router and redirect middleware for Let's Encrypt challenges
- Fix CMS Dockerfile to copy config from source (not dist)
- Add favicon.png to CMS container
- Enable automatic HTTPS redirect for all services
2026-02-24 17:52:51 +01:00
2efdf20f88 refactor: use npm prune --omit=dev instead of deprecated --production flag 2026-02-24 17:41:38 +01:00
ed723bc429 fix: use 'https' entrypoint instead of 'websecure' for Traefik 2026-02-24 17:34:51 +01:00
b8dfde512c feat: add manual Traefik labels and coolify network to all services 2026-02-24 17:16:28 +01:00
5fc424408e fix: correct health check endpoints and use IPv4 addresses 2026-02-24 16:49:17 +01:00
749ff5659c port conf 2026-02-24 16:44:40 +01:00
a2caaab26a devops again 2026-02-24 15:13:50 +01:00
bc3e834a39 tr 2026-02-24 14:39:41 +01:00
7a96b52cfc devops doodle 2026-02-24 14:17:29 +01:00
5233eec96a devopse secret 2026-02-24 14:01:10 +01:00
831da0af4d messing up deployment 2026-02-24 13:49:03 +01:00
a9528582fd strapi config 2026-02-24 13:31:31 +01:00
ee676b3916 db config 2026-02-24 13:15:47 +01:00
9013bc51f3 docker files fix 2026-02-24 12:58:38 +01:00
57ae0bb3b5 cool up 2026-02-23 06:21:45 +01:00
3b827a90ac prod update 2026-02-23 05:54:35 +01:00
3af4b92e1b legacy 2026-02-23 05:28:46 +01:00
cfc43a7119 docker fix 2026-02-23 05:18:40 +01:00
dc93a8b652 compose coolify 2026-02-23 04:51:14 +01:00
69833d2067 prod 2026-02-22 05:30:47 +01:00
d041575600 logs removed 2026-02-22 04:49:38 +01:00
57864e00da push notification
fix implemented
2026-02-22 04:00:20 +01:00
0bbf2ab56f push notification implemented
need testing
2026-02-22 02:23:41 +01:00
26a17b5a4c Merge branch 'pwa' 2026-02-22 01:27:13 +01:00
7725674fd5 implemented 2026-02-22 01:20:59 +01:00
f1f16a80a5 pwa ready 2026-02-22 01:11:20 +01:00
83c60ce40d ag 2026-02-22 00:44:46 +01:00
f20842b3ab xxl 2026-02-16 21:50:11 +01:00
8510cccb6e buymeacoffe added 2026-02-16 21:20:58 +01:00
c2c77ac92a Merge branch 'redesignNo1' 2026-02-16 20:45:29 +01:00
71b1b549c3 admin dashboard fixed
maybe we need another design for this page
2026-02-16 20:42:11 +01:00
aa79eba06d pading fixed on pages 2026-02-16 19:52:37 +01:00
62c68e4bc6 scrollbar on every page 2026-02-16 19:47:24 +01:00
2be6216772 date fixed
also прекршени вести Ч)
2026-02-16 19:41:00 +01:00
ae28f56337 header transparency fix 2026-02-16 19:19:27 +01:00
10fe702749 liveblog update fix 2026-02-16 19:15:31 +01:00
4c4d741b1f redesign looking good 2026-02-16 18:50:33 +01:00
6abae13dbd run 2026-02-16 18:16:06 +01:00
46eb41aaa5 category field added
implemented ctegory logic, ditional pages added
2026-02-06 03:35:28 +01:00
add12b2fbf hero section added
hero article menagment implemented in admin UI
2026-02-06 02:14:10 +01:00
6d65d5975c mobile nav implemented
dark mode need rifinements
2026-02-06 01:04:43 +01:00
b8779e5a35 social share up and running 2026-02-05 03:04:19 +01:00
000ebd388a article detail page @public 2026-02-05 00:10:23 +01:00
3374eb1ec0 reply to comment implemented 2026-02-04 23:51:37 +01:00
d7b82c0ec9 comment func implemented 2026-02-04 23:08:47 +01:00
3718882f5b another auth checkpoint
webhook issue resolved
2026-02-04 22:36:28 +01:00
cb2355adcd types import error fixed 2026-02-04 20:37:46 +01:00
c6e602d354 docker dependecies fixed 2026-02-04 20:03:17 +01:00
42002f8e6f auth checkpoint
auth dependecies not instaled in dev container
2026-02-04 19:24:03 +01:00
7378d37b36 sync working as intended 2026-02-04 00:52:05 +01:00
9ca05f5ea1 delete/archive implemented 2026-02-03 23:14:36 +01:00
89b8687431 video func implemented 2026-02-03 21:18:25 +01:00
3bc534489d img positioning fixed 2026-02-03 20:03:46 +01:00
6467e21019 markdown img insert work as intended 2026-02-03 19:53:29 +01:00
28609a6492 img shown succesfully 2026-02-03 18:47:42 +01:00
7d3bc2a014 setup completed 2026-02-03 17:45:38 +01:00
acfbc80d1a unpublish fix 2026-02-01 13:57:16 +01:00
0c2433cac6 docker implementation 2026-02-01 13:32:37 +01:00
7c7bb45446 admin dashboard 2026-01-29 11:27:45 +01:00
2ff76ffda5 fixed strapi webhook endpoint 2026-01-29 09:40:23 +01:00
284 changed files with 77024 additions and 6637 deletions

276
ADMINISTRATOR_GUIDE.md Normal file
View 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
View File

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View File

@ -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
View 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
View 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"]

Binary file not shown.

BIN
backend/database.sqlite.old Normal file

Binary file not shown.

13
backend/docker-entrypoint.sh Executable file
View 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

1249
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -19,7 +19,13 @@
"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",
@ -27,14 +33,26 @@
"@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",
@ -46,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",

View 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();

View 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
View 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"

View 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();

View 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();

View File

@ -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(),
};
}
}

View File

@ -1,18 +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 { 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: [
@ -20,17 +32,43 @@ import {
isGlobal: true,
}),
TypeOrmModule.forRoot({
type: 'sqlite',
database: process.env.DATABASE_PATH ?? './database.sqlite',
entities: [Article, Author, Category, LiveBlog, LiveBlogUpdate],
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 {}

View File

@ -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();

View 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();
}
}

View 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;
}

View 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;
}

View 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 {}

View 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,
};
}
}

View File

@ -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);
}
}

View File

@ -6,9 +6,15 @@ import {
IsUUID,
IsNumber,
IsBoolean,
IsDate,
IsDateString,
} from 'class-validator';
import { ArticleStatus, LiveBlogStatus } from './entities';
import {
ArticleStatus,
LiveBlogStatus,
ImagePosition,
ImageSize,
VideoPosition,
} from './entities';
export class CreateArticleDto {
@IsString()
@ -42,6 +48,10 @@ export class CreateArticleDto {
@IsString()
strapiId?: string;
@IsOptional()
@IsBoolean()
isHero?: boolean;
@IsOptional()
@IsUUID()
authorId?: string;
@ -49,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 {
@ -85,6 +139,10 @@ export class UpdateArticleDto {
@IsString()
strapiId?: string;
@IsOptional()
@IsBoolean()
isHero?: boolean;
@IsOptional()
@IsUUID()
authorId?: string;
@ -92,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 {
@ -108,8 +210,37 @@ export class FindArticlesDto {
tag?: string;
@IsOptional()
@IsEnum(ArticleStatus)
status?: ArticleStatus;
@IsString()
status?: string;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsNumber()
page?: number;
@IsOptional()
limit?: number;
}
export class FindLiveBlogsDto {
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsString()
author?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsBoolean()
isPinned?: boolean;
@IsOptional()
@IsString()
@ -153,6 +284,30 @@ export class CreateLiveBlogDto {
@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 {
@ -187,6 +342,30 @@ export class UpdateLiveBlogDto {
@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 {
@ -198,16 +377,24 @@ export class CreateLiveBlogUpdateDto {
isPinned?: boolean;
@IsOptional()
@IsUUID()
@IsString()
authorId?: string;
@IsOptional()
@IsDate()
scheduledAt?: Date;
@IsDateString()
scheduledAt?: string;
@IsOptional()
@IsString()
strapiId?: string;
image?: string;
@IsOptional()
@IsString()
videoUrl?: string;
@IsOptional()
@IsString()
authorName?: string;
}
export class UpdateLiveBlogUpdateDto {
@ -220,43 +407,18 @@ export class UpdateLiveBlogUpdateDto {
isPinned?: boolean;
@IsOptional()
@IsUUID()
@IsString()
authorId?: string;
@IsOptional()
@IsDate()
scheduledAt?: Date;
@IsString()
image?: string;
@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;
videoUrl?: string;
@IsOptional()
@IsString()
authorName?: string;
}

View 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;
}

View File

@ -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],

View File

@ -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' },
});
}
}

View 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);
}
}

View 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 {}

View 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,
},
};
}
}

View 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);
}
}

View 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);
}
}

View 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,
};
}
}

View File

@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

View 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;
}
}

View File

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '../entities';
export const Roles = (...roles: UserRole[]) => SetMetadata('roles', roles);

View 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);
}
}

View 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;
};
}

View 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',
);
}
}

View 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;
}

View 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 {}

View 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,
};
}
}

View File

@ -36,6 +36,37 @@ export enum LiveBlogStatus {
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')
@ -94,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')
@ -114,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: '[]',
@ -130,7 +215,7 @@ export class Article {
@Column({ default: 0 })
views: number;
@Column({ nullable: true })
@Column({ nullable: true, unique: true })
strapiId: string;
@Column({ nullable: true })
@ -139,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;
@ -152,6 +273,12 @@ 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')
@ -177,7 +304,7 @@ export class LiveBlog {
@Column({ default: false })
isPinned: boolean;
@Column({ nullable: true })
@Column({ nullable: true, unique: true })
strapiId: string;
@Column({ nullable: true })
@ -186,6 +313,33 @@ export class LiveBlog {
@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;
@ -207,6 +361,12 @@ export class LiveBlog {
cascade: true,
})
updates: LiveBlogUpdate[];
@OneToMany(() => Comment, (comment) => comment.liveBlog)
comments: Comment[];
@OneToMany(() => Reaction, (reaction) => reaction.liveBlog)
reactions: Reaction[];
}
@Entity('live_blog_updates')
@ -223,7 +383,7 @@ export class LiveBlogUpdate {
@Column({ nullable: true })
authorId: string;
@Column({ type: 'datetime', nullable: true })
@Column({ type: 'timestamp', nullable: true })
scheduledAt: Date;
@Column({ nullable: true })
@ -243,3 +403,100 @@ export class LiveBlogUpdate {
@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;
}

View File

@ -4,13 +4,14 @@ import {
Post,
Put,
Delete,
Patch,
Body,
Param,
Query,
ValidationPipe,
Res,
Logger,
Headers,
UseGuards,
} from '@nestjs/common';
import type { Response as ExpressResponse } from 'express';
import { LiveBlogService } from './live-blog.service';
@ -21,6 +22,11 @@ import {
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 {
@ -30,68 +36,76 @@ export class LiveBlogController {
// 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()
create(
@Body(new ValidationPipe({ transform: true })) dto: CreateLiveBlogDto,
) {
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
create(@Body() dto: CreateLiveBlogDto) {
return this.liveBlogService.create(dto);
}
@Get()
findAll(
@Query(new ValidationPipe({ transform: true })) dto: FindLiveBlogsDto,
) {
@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')
update(
@Param('id') id: string,
@Body(new ValidationPipe({ transform: true })) dto: UpdateLiveBlogDto,
) {
@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(new ValidationPipe({ transform: true })) dto: CreateLiveBlogUpdateDto,
@Body() dto: CreateLiveBlogUpdateDto,
) {
return this.liveBlogService.createUpdate(dto, liveBlogId);
}
@Get(':id/updates')
@Public()
findUpdates(
@Param('id') liveBlogId: string,
@Query('page') page = 1,
@ -105,15 +119,19 @@ export class LiveBlogController {
}
@Put(':id/updates/:updateId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
updateUpdate(
@Param('id') liveBlogId: string,
@Param('updateId') updateId: string,
@Body(new ValidationPipe({ transform: true })) dto: UpdateLiveBlogUpdateDto,
@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,
@ -121,8 +139,23 @@ export class LiveBlogController {
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,

View File

@ -1,14 +1,18 @@
import { Module } from '@nestjs/common';
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],

View File

@ -3,12 +3,16 @@ import {
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,
@ -16,6 +20,7 @@ import {
CreateLiveBlogUpdateDto,
UpdateLiveBlogUpdateDto,
} from './articles.dto';
import { PushService } from './push/push.service';
interface SseClient {
id: string;
@ -50,6 +55,11 @@ export class LiveBlogService implements OnModuleInit {
@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() {
@ -116,13 +126,9 @@ export class LiveBlogService implements OnModuleInit {
} else {
queryBuilder.where('liveBlog.status = :status', { status });
}
} else if (!isPinned) {
// Default to live blogs if no status specified AND not querying for pinned blogs
// Pinned blogs can be either live or ended
queryBuilder.where('liveBlog.status = :status', {
status: LiveBlogStatus.LIVE,
});
}
// 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 });
@ -252,6 +258,22 @@ export class LiveBlogService implements OnModuleInit {
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;
@ -259,9 +281,74 @@ export class LiveBlogService implements OnModuleInit {
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,
@ -282,6 +369,22 @@ export class LiveBlogService implements OnModuleInit {
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;
}
@ -376,8 +479,19 @@ export class LiveBlogService implements OnModuleInit {
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}`,
@ -414,21 +528,91 @@ export class LiveBlogService implements OnModuleInit {
strapiId: string,
data: Partial<CreateLiveBlogDto>,
): Promise<LiveBlog> {
let liveBlog = await this.liveBlogRepository.findOne({
// 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) {
liveBlog = this.liveBlogRepository.create({
strapiId,
...data,
status: data.status || LiveBlogStatus.DRAFT,
});
} else {
Object.assign(liveBlog, data);
this.logger.warn(`LiveBlog with strapiId ${strapiId} not found`);
return;
}
return await this.liveBlogRepository.save(liveBlog);
await this.liveBlogRepository.remove(liveBlog);
this.logger.log(
`Successfully deleted live blog with strapiId: ${strapiId}`,
);
}
// Utility methods

View 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;
}

View 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',
});
}
}

View 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;
}

View 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 {}

View 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 };
}
}

View File

@ -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,45 +17,131 @@ 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: 'Articles sync completed' };
}
@Post('live-blog')
async handleLiveBlogWebhook(@Body() body: WebhookBody) {
const { event, model, entry } = body;
if (model !== 'live-blog') {
return { message: 'Ignored: not a live blog' };
}
await this.strapiService.handleWebhook(event, entry);
return { message: 'Live blog webhook processed successfully' };
@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(),

View File

@ -1,12 +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, LiveBlogModule],
imports: [
HttpModule,
TypeOrmModule.forFeature([Category]),
forwardRef(() => ArticlesModule),
forwardRef(() => LiveBlogModule),
],
controllers: [StrapiController],
providers: [StrapiService],
exports: [StrapiService],

View File

@ -1,11 +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 { LiveBlogService } from './live-blog.service';
import { CreateArticleDto, CreateLiveBlogDto } from './articles.dto';
import { ArticleStatus, LiveBlogStatus } from './entities';
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;
@ -17,6 +41,14 @@ 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 {
@ -29,6 +61,14 @@ interface StrapiLiveBlog {
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> {
@ -47,28 +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> {
@ -77,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(),
},
@ -88,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(
@ -115,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(),
},
@ -129,28 +313,92 @@ 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}`);
} catch (error) {
this.logger.error(
`Failed to sync article ${strapiId} from Strapi`,
error,
this.logger.log(
`Successfully synced article: ${strapiArticle.title} with status: ${status}`,
);
throw error;
} 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;
}
}
}
@ -160,7 +408,7 @@ export class StrapiService {
const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiLiveBlog[]>>(
`${this.strapiUrl}/api/live-blogs`,
`${this.strapiUrl}/api/live-blogs?populate=*`,
{
headers: this.getHeaders(),
},
@ -171,11 +419,33 @@ export class StrapiService {
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(
@ -194,13 +464,23 @@ export class StrapiService {
}
}
async syncSingleLiveBlog(strapiId: string): Promise<void> {
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}`);
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}`,
`${this.strapiUrl}/api/live-blogs/${strapiId}?populate=*`,
{
headers: this.getHeaders(),
},
@ -208,24 +488,83 @@ export class StrapiService {
);
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: this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status),
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}`);
} catch (error) {
this.logger.error(
`Failed to sync live blog ${strapiId} from Strapi`,
error,
this.logger.log(
`Successfully synced live blog: ${strapiLiveBlog.title} with status: ${status}`,
);
throw error;
} 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;
}
}
}
@ -245,7 +584,12 @@ export class StrapiService {
}
async handleWebhook(
event: 'entry.create' | 'entry.update' | 'entry.delete',
event:
| 'entry.create'
| 'entry.update'
| 'entry.delete'
| 'entry.publish'
| 'entry.unpublish',
data: { documentId: string; model?: string },
): Promise<void> {
this.logger.log(
@ -253,17 +597,185 @@ export class StrapiService {
);
if (event === 'entry.delete') {
this.logger.log(`Handling delete for document: ${data.documentId}`);
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);
await this.syncSingleArticle(data.documentId, event);
} else if (data.model === 'live-blog') {
await this.syncSingleLiveBlog(data.documentId);
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 update article ${strapiId} status in Strapi:`,
error,
);
throw error;
}
}
async updateLiveBlogStatusInStrapi(
strapiId: string,
status: LiveBlogStatus,
): Promise<void> {
try {
this.logger.log(
`Updating live blog ${strapiId} status in Strapi to: ${status}`,
);
// 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;
}
}
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;
}
}
}

View 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 });
}
}

View 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;
}

View 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 {}

View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
ad31a55be0b5efc2d6d7b5490e8d1a31b1a3a2aecbcf99e8f65bcb44bd0189e2918388b942fb3ac7b35fd470d19d475e556489b773c68756977e452aec1ab83f34876ed76ebadafce31636d5621c66820425d1105d753cdc5452d8f3d503ddbaebf45fc6817c235e1f8eae12d118452951ee0a48691446475f7ffc6a72fd6ffb

View File

@ -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'),
},
});

View File

@ -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),
},
};
};

View File

@ -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',

View File

@ -1 +1,7 @@
export default () => ({});
export default ({ env }) => ({
upload: {
config: {
sizeLimit: 200 * 1024 * 1024, // 200MB in bytes
},
},
});

View File

@ -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'),
},

View 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
View File

@ -0,0 +1,2 @@
api token=5af45b836a0bcd065b528963e62b6d5d325117dfbf179cd5123087a17906c911fd67bcb57d92f11e869ebda27339b21aa3a7221d3dbbe79de51ef2f9e2af4dae0befe6528872ca2f68f64656ed45c7dfcacea36fdb55d1d9eb2fd9275454ac7ff8ab9acb1535f62449b3f8bd75c24803a2bd0714c637fd1d0bc819798723d999

View File

@ -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",

View 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();

View File

@ -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
}
}
}

View File

@ -0,0 +1,7 @@
/**
* article controller
*/
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::article.article');

View File

@ -1,7 +0,0 @@
/**
* article controller
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::article.article');

View File

@ -0,0 +1,7 @@
/**
* article router
*/
const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::article.article');

View File

@ -1,7 +0,0 @@
/**
* article router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::article.article');

View File

@ -0,0 +1,7 @@
/**
* article service
*/
const { createCoreService } = require('@strapi/strapi').factories;
module.exports = createCoreService('api::article.article');

View File

@ -1,7 +0,0 @@
/**
* article service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::article.article');

View File

@ -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']);
},
};

View File

@ -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
View 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
View 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
View 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
View 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

View File

@ -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.

View File

@ -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/, ''),
},
},
},
}
})
```
---

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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]

View File

@ -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