Compare commits
107
Commits
admintake
...
46bbeed525
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46bbeed525 | ||
|
|
7258f059ce | ||
|
|
a66db56156 | ||
|
|
ebd1fc28d5 | ||
|
|
cba9908180 | ||
|
|
287dcb4070 | ||
|
|
50ac4531d3 | ||
|
|
79bd134977 | ||
|
|
88dbd2719d | ||
|
|
e71dc2b90f | ||
|
|
caf56d9fcd | ||
|
|
86c1c2d366 | ||
|
|
4f2837ad4e | ||
|
|
c6427304ac | ||
|
|
515c4cacf3 | ||
|
|
59891b03dc | ||
|
|
8f68dab53f | ||
|
|
fa7dcc2b08 | ||
|
|
3e61fe5694 | ||
|
|
a5008a3646 | ||
|
|
cbdb801655 | ||
|
|
a5c57b33f7 | ||
|
|
b39972102a | ||
|
|
8fbde18d02 | ||
|
|
94b0239a0a | ||
|
|
ac610e6f6a | ||
|
|
8eaaf4afad | ||
|
|
33008b64ce | ||
|
|
005d368dc7 | ||
|
|
a8c0ab8884 | ||
|
|
e443ece848 | ||
|
|
76963f6eea | ||
|
|
bfc7e76f17 | ||
|
|
06a0c9fe05 | ||
|
|
c1b49a4cb6 | ||
|
|
4f30542014 | ||
|
|
d7281024bf | ||
|
|
4a22e8a18a | ||
|
|
994becc687 | ||
|
|
69e758b841 | ||
|
|
17c6a1593c | ||
|
|
d573846e5c | ||
|
|
c3b01de12e | ||
|
|
80a2ee89a6 | ||
|
|
ff0916b37c | ||
|
|
6eb68b7bd3 | ||
|
|
fcbd082f6d | ||
|
|
183636bceb | ||
|
|
5262e73a12 | ||
|
|
590a96e502 | ||
|
|
0ef26ba2f1 | ||
|
|
2efdf20f88 | ||
|
|
ed723bc429 | ||
|
|
b8dfde512c | ||
|
|
5fc424408e | ||
|
|
749ff5659c | ||
|
|
a2caaab26a | ||
|
|
bc3e834a39 | ||
|
|
7a96b52cfc | ||
|
|
5233eec96a | ||
|
|
831da0af4d | ||
|
|
a9528582fd | ||
|
|
ee676b3916 | ||
|
|
9013bc51f3 | ||
|
|
57ae0bb3b5 | ||
|
|
3b827a90ac | ||
|
|
3af4b92e1b | ||
|
|
cfc43a7119 | ||
|
|
dc93a8b652 | ||
|
|
69833d2067 | ||
|
|
d041575600 | ||
|
|
57864e00da | ||
|
|
0bbf2ab56f | ||
|
|
26a17b5a4c | ||
|
|
7725674fd5 | ||
|
|
f1f16a80a5 | ||
|
|
83c60ce40d | ||
|
|
f20842b3ab | ||
|
|
8510cccb6e | ||
|
|
c2c77ac92a | ||
|
|
71b1b549c3 | ||
|
|
aa79eba06d | ||
|
|
62c68e4bc6 | ||
|
|
2be6216772 | ||
|
|
ae28f56337 | ||
|
|
10fe702749 | ||
|
|
4c4d741b1f | ||
|
|
6abae13dbd | ||
|
|
46eb41aaa5 | ||
|
|
add12b2fbf | ||
|
|
6d65d5975c | ||
|
|
b8779e5a35 | ||
|
|
000ebd388a | ||
|
|
3374eb1ec0 | ||
|
|
d7b82c0ec9 | ||
|
|
3718882f5b | ||
|
|
cb2355adcd | ||
|
|
c6e602d354 | ||
|
|
42002f8e6f | ||
|
|
7378d37b36 | ||
|
|
9ca05f5ea1 | ||
|
|
89b8687431 | ||
|
|
3bc534489d | ||
|
|
6467e21019 | ||
|
|
28609a6492 | ||
|
|
7d3bc2a014 | ||
|
|
acfbc80d1a |
No files matched your search
@@ -1,151 +1,169 @@
|
|||||||
# Agent Guidelines for placebo.mk
|
# Agent Guidelines for placebo.mk
|
||||||
|
|
||||||
## Project Overview
|
## 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
|
## 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
|
- **Backend**: NestJS + TypeORM + SQLite
|
||||||
- **CMS**: Strapi (in /cms)
|
- **CMS**: Strapi (in /cms)
|
||||||
|
|
||||||
## Build Commands
|
## Commands
|
||||||
|
|
||||||
### Backend (NestJS)
|
### Root Level
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
npm run dev:local # Start backend & frontend locally
|
||||||
npm install
|
npm run dev:docker # Start via docker-compose
|
||||||
npm run start:dev # Watch mode
|
npm run lint # Lint both projects
|
||||||
npm run build
|
npm run lint:fix # Auto-fix lint issues
|
||||||
npm run start:prod
|
npm run type-check # Type check both projects
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend (TanStack)
|
### Backend (cd backend)
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
npm run start:dev # Watch mode
|
||||||
npm install
|
npm run lint / lint:fix # ESLint
|
||||||
npm run dev # Vite dev server
|
npm run type-check # TypeScript check
|
||||||
npm run build
|
npm test # All tests
|
||||||
npm run preview
|
npm test app.service.spec # Single file (omit .ts)
|
||||||
npm run lint
|
npm test -t "should return" # By pattern
|
||||||
npm run lint:fix
|
npm test:cov # Coverage
|
||||||
npm run type-check
|
npm test:e2e # E2E tests
|
||||||
npm test # All tests
|
```
|
||||||
npm test Header.test.tsx # Single file
|
|
||||||
npm test -t "renders" # By name
|
### Frontend (cd frontend)
|
||||||
npm run test:ui # Vitest UI
|
```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
|
## Code Style
|
||||||
|
|
||||||
### Formatting & TypeScript
|
### TypeScript
|
||||||
- Use Prettier (2 spaces, single quotes, trailing commas, 100 char max)
|
- Strict mode: `noImplicitAny`, `strictNullChecks`, `noFallthroughCasesInSwitch`
|
||||||
- Strict TypeScript - no implicit `any`, avoid `any`, use `unknown`
|
- No implicit `any` - use `unknown` when type uncertain
|
||||||
- Explicit return types for public methods
|
- Explicit return types for public methods
|
||||||
- Prefer interfaces over types for objects
|
- Prefer `interface` over `type` for objects
|
||||||
- Use `readonly` for immutable properties
|
- Use `readonly` for immutable properties
|
||||||
|
|
||||||
### Naming Conventions
|
### Formatting (Prettier)
|
||||||
- **Files**: kebab-case (`user-profile.ts`, `auth.service.ts`)
|
- Single quotes, trailing commas, 2-space indent
|
||||||
- **Classes**: PascalCase (`UserService`, `AuthGuard`)
|
- No semicolons in comments
|
||||||
- **Functions/Variables**: camelCase (`getUser`, `isLoading`)
|
|
||||||
- **Constants**: UPPER_SNAKE_CASE (`MAX_RETRIES`)
|
|
||||||
- **Private members**: underscore prefix (`_cache`, `_validate()`)
|
|
||||||
- **Interfaces**: PascalCase, no 'I' prefix (`User`, `ApiResponse`)
|
|
||||||
|
|
||||||
### Import Order
|
### Naming
|
||||||
- External libraries first, then internal modules, then relative imports
|
| 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
|
### File Structure
|
||||||
```
|
```
|
||||||
backend/src/
|
backend/src/
|
||||||
modules/feature-name/
|
modules/{feature}/
|
||||||
feature-name.module.ts
|
{feature}.module.ts
|
||||||
feature-name.controller.ts
|
{feature}.controller.ts
|
||||||
feature-name.service.ts
|
{feature}.service.ts
|
||||||
feature-name.entity.ts
|
{feature}.dto.ts
|
||||||
feature-name.dto.ts
|
|
||||||
common/{decorators,filters,guards,interceptors,pipes}
|
common/{decorators,filters,guards,interceptors,pipes}
|
||||||
config/
|
entities.ts # All TypeORM entities
|
||||||
|
|
||||||
frontend/src/
|
frontend/src/
|
||||||
components/{ui,layout,features}/
|
components/{ui,layout,features,admin,routes}/
|
||||||
hooks/
|
hooks/ # Custom React hooks
|
||||||
lib/
|
queries/ # TanStack Query hooks
|
||||||
queries/
|
lib/ # Utilities, API client
|
||||||
routes/
|
routes/ # TanStack Router routes
|
||||||
types/
|
types/ # TypeScript types
|
||||||
utils/
|
```
|
||||||
|
|
||||||
|
### 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
|
### 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
|
- **Frontend**: Error boundaries, try-catch with user-friendly messages
|
||||||
|
|
||||||
### API Conventions
|
### API Response Format
|
||||||
- REST endpoints: `/api/v1/resources`
|
```typescript
|
||||||
- JSON fields: snake_case
|
{ data: T, meta: { total, page, limit }, error?: { code, message } }
|
||||||
- Pagination: `page`, `limit` query params
|
```
|
||||||
- Response format:
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
data: T,
|
|
||||||
meta: { total, page, limit },
|
|
||||||
error?: { code, message }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing
|
## Testing
|
||||||
- Unit tests for services/hooks (AAA pattern: Arrange-Act-Assert)
|
- **Backend**: Jest (`*.spec.ts`), AAA pattern, mock dependencies
|
||||||
- Integration tests for API endpoints
|
- **Frontend**: Vitest + React Testing Library (`*.test.tsx`)
|
||||||
- E2E tests for critical user flows
|
- Descriptive names: `should return user data when valid ID provided`
|
||||||
- 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
|
|
||||||
|
|
||||||
### Git Workflow
|
## Git Commits
|
||||||
- Conventional commits: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`
|
- Conventional: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`
|
||||||
- Keep commits atomic and focused
|
- Atomic, imperative mood
|
||||||
- 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
|
|
||||||
|
|
||||||
|
## 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
|
||||||
@@ -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
|
||||||
+631
-371
File diff suppressed because it is too large.
Load diff
@@ -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`
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+31
-4
@@ -1,7 +1,34 @@
|
|||||||
PORT=3000
|
# Environment Configuration Template
|
||||||
DATABASE_PATH=./database.sqlite
|
# Copy to .env and adjust values for your setup
|
||||||
NODE_ENV=development
|
|
||||||
FRONTEND_URL=http://localhost:5173
|
|
||||||
|
|
||||||
|
# ===== 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
|
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
|
STRAPI_API_TOKEN=your-strapi-api-token-here
|
||||||
+7
-8
@@ -6,11 +6,10 @@ FROM node:20-alpine AS builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package.json package-lock.json* ./
|
||||||
COPY package-lock.json* ./
|
|
||||||
|
|
||||||
# Install dependencies
|
# Install ALL dependencies (including devDependencies for build)
|
||||||
RUN npm ci --only=production
|
RUN npm install --legacy-peer-deps
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -18,6 +17,9 @@ COPY . .
|
|||||||
# Build TypeScript
|
# Build TypeScript
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# Prune devDependencies after build (suppress warning)
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
@@ -32,9 +34,6 @@ 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/node_modules ./node_modules
|
||||||
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
|
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
|
||||||
|
|
||||||
# Copy environment configuration
|
|
||||||
COPY --chown=nodejs:nodejs .env.example .env
|
|
||||||
|
|
||||||
# Switch to non-root user
|
# Switch to non-root user
|
||||||
USER nodejs
|
USER nodejs
|
||||||
|
|
||||||
@@ -46,4 +45,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Start application
|
# Start application
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/src/main.js"]
|
||||||
@@ -4,6 +4,9 @@ FROM node:20-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install build dependencies for native modules
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
# Install dependencies with better error handling
|
# Install dependencies with better error handling
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
COPY package-lock.json* ./
|
COPY package-lock.json* ./
|
||||||
|
|||||||
Executable
+13
@@ -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
|
||||||
Generated
+739
-373
File diff suppressed because it is too large.
Load diff
+19
-2
@@ -19,7 +19,13 @@
|
|||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"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": {
|
"dependencies": {
|
||||||
"@nestjs/axios": "^4.0.1",
|
"@nestjs/axios": "^4.0.1",
|
||||||
@@ -27,15 +33,25 @@
|
|||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
"@nestjs/event-emitter": "^3.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/platform-express": "^11.0.1",
|
||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/passport-local": "^1.0.38",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"passport-local": "^1.0.0",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"typeorm": "^0.3.28"
|
"typeorm": "^0.3.28",
|
||||||
|
"web-push": "^3.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
@@ -47,6 +63,7 @@
|
|||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-prettier": "^5.2.2",
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
Executable
+12
@@ -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"
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { Category } from '../src/modules/entities';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.createApplicationContext(AppModule);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const categoryRepository = app.get(getRepositoryToken(Category));
|
||||||
|
|
||||||
|
// Define categories with Macedonian names and English slugs
|
||||||
|
const categories = [
|
||||||
|
{
|
||||||
|
name: 'Спорт',
|
||||||
|
slug: 'sport',
|
||||||
|
description: 'Спортски вести и анализи',
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Уметност',
|
||||||
|
slug: 'art',
|
||||||
|
description: 'Уметност, култура и забава',
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Наука',
|
||||||
|
slug: 'science',
|
||||||
|
description: 'Научни откритија и технологија',
|
||||||
|
order: 3,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('Seeding categories...');
|
||||||
|
|
||||||
|
for (const categoryData of categories) {
|
||||||
|
// Check if category already exists
|
||||||
|
const existingCategory = await categoryRepository.findOne({
|
||||||
|
where: { slug: categoryData.slug },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingCategory) {
|
||||||
|
console.log(`Category "${categoryData.name}" (${categoryData.slug}) already exists`);
|
||||||
|
} else {
|
||||||
|
const category = categoryRepository.create(categoryData);
|
||||||
|
await categoryRepository.save(category);
|
||||||
|
console.log(`Created category: "${categoryData.name}" (${categoryData.slug})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✅ Category seeding completed!');
|
||||||
|
console.log('Categories available:');
|
||||||
|
const allCategories = await categoryRepository.find({ order: { order: 'ASC' } });
|
||||||
|
allCategories.forEach((cat: Category) => {
|
||||||
|
console.log(` • ${cat.name} (${cat.slug}) - ${cat.description || 'No description'}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error seeding categories:', error);
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
|
import { Public } from './modules/auth/public.decorator';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
@@ -9,4 +10,13 @@ export class AppController {
|
|||||||
getHello(): string {
|
getHello(): string {
|
||||||
return this.appService.getHello();
|
return this.appService.getHello();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('health')
|
||||||
|
healthCheck(): { status: string; timestamp: string } {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,30 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
import { ArticlesModule } from './modules/articles.module';
|
import { ArticlesModule } from './modules/articles.module';
|
||||||
import { StrapiModule } from './modules/strapi.module';
|
import { StrapiModule } from './modules/strapi.module';
|
||||||
import { LiveBlogModule } from './modules/live-blog.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 {
|
import {
|
||||||
Article,
|
Article,
|
||||||
Author,
|
Author,
|
||||||
Category,
|
Category,
|
||||||
LiveBlog,
|
LiveBlog,
|
||||||
LiveBlogUpdate,
|
LiveBlogUpdate,
|
||||||
|
User,
|
||||||
|
Comment,
|
||||||
|
Reaction,
|
||||||
} from './modules/entities';
|
} 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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -26,15 +38,37 @@ import {
|
|||||||
username: process.env.DATABASE_USERNAME || 'placebo_user',
|
username: process.env.DATABASE_USERNAME || 'placebo_user',
|
||||||
password: process.env.DATABASE_PASSWORD || 'placebo_password',
|
password: process.env.DATABASE_PASSWORD || 'placebo_password',
|
||||||
database: process.env.DATABASE_NAME || 'placebo_backend_db',
|
database: process.env.DATABASE_NAME || 'placebo_backend_db',
|
||||||
entities: [Article, Author, Category, LiveBlog, LiveBlogUpdate],
|
entities: [
|
||||||
synchronize: process.env.NODE_ENV !== 'production',
|
Article,
|
||||||
logging: process.env.NODE_ENV === 'development',
|
Author,
|
||||||
|
Category,
|
||||||
|
LiveBlog,
|
||||||
|
LiveBlogUpdate,
|
||||||
|
User,
|
||||||
|
Comment,
|
||||||
|
Reaction,
|
||||||
|
ShareEvent,
|
||||||
|
PushSubscriptionEntity,
|
||||||
|
],
|
||||||
|
synchronize: process.env.DATABASE_SYNCHRONIZE === 'true',
|
||||||
|
logging: process.env.DATABASE_LOGGING === 'true',
|
||||||
}),
|
}),
|
||||||
ArticlesModule,
|
ArticlesModule,
|
||||||
StrapiModule,
|
StrapiModule,
|
||||||
LiveBlogModule,
|
LiveBlogModule,
|
||||||
|
UserModule,
|
||||||
|
AuthModule,
|
||||||
|
CommentModule,
|
||||||
|
AnalyticsModule,
|
||||||
|
PushModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [
|
||||||
|
AppService,
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: JwtAuthPublicGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
+22
-3
@@ -1,25 +1,44 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
// Build allowed origins list from environment variables
|
||||||
const allowedOrigins = [
|
const allowedOrigins = [
|
||||||
process.env.FRONTEND_URL ?? 'http://localhost:5173',
|
process.env.FRONTEND_URL ?? 'http://localhost:5173',
|
||||||
|
'https://www.placebo.mk', // Also allow www subdomain
|
||||||
|
process.env.PWA_URL ?? 'http://localhost:5174',
|
||||||
process.env.STRAPI_URL ?? 'http://localhost:1337',
|
process.env.STRAPI_URL ?? 'http://localhost:1337',
|
||||||
];
|
].filter(Boolean); // Remove any undefined/null values
|
||||||
|
|
||||||
|
console.log('CORS enabled for origins:', allowedOrigins);
|
||||||
|
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: allowedOrigins,
|
origin: allowedOrigins,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization', 'Accept', 'Origin'],
|
||||||
|
exposedHeaders: ['Content-Range', 'X-Content-Range'],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
|
maxAge: 3600,
|
||||||
});
|
});
|
||||||
|
|
||||||
app.setGlobalPrefix('api/v1');
|
app.setGlobalPrefix('api/v1');
|
||||||
|
|
||||||
|
// Apply global validation pipe
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
transform: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const port = process.env.PORT ?? 3000;
|
const port = process.env.PORT ?? 3000;
|
||||||
const host = '0.0.0.0'; // Bind to all interfaces for Docker
|
const host = '0.0.0.0'; // Bind to all interfaces for Docker
|
||||||
await app.listen(port, host);
|
await app.listen(port, host);
|
||||||
|
|
||||||
console.log(`Application is running on: http://${host}:${port}`);
|
console.log(`Application is running on: http://${host}:${port}`);
|
||||||
}
|
}
|
||||||
void bootstrap();
|
void bootstrap();
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||||
|
import type { SharePlatform } from './analytics.entity';
|
||||||
|
|
||||||
|
export class TrackShareDto {
|
||||||
|
@IsUUID()
|
||||||
|
articleId: string;
|
||||||
|
|
||||||
|
@IsEnum(['facebook', 'twitter', 'whatsapp', '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;
|
||||||
|
whatsappShares: number;
|
||||||
|
telegramShares: number;
|
||||||
|
linkShares: number;
|
||||||
|
totalShares: number;
|
||||||
|
views: number;
|
||||||
|
shareRate: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Article } from '../entities';
|
||||||
|
|
||||||
|
export type SharePlatform =
|
||||||
|
| 'facebook'
|
||||||
|
| 'twitter'
|
||||||
|
| 'whatsapp'
|
||||||
|
| '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;
|
||||||
|
whatsappShares: number;
|
||||||
|
telegramShares: number;
|
||||||
|
linkShares: number;
|
||||||
|
totalShares: number;
|
||||||
|
views: number;
|
||||||
|
shareRate: number;
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
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 'whatsapp':
|
||||||
|
return 'whatsappShares';
|
||||||
|
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.whatsappShares as "whatsappShares"',
|
||||||
|
'article.telegramShares as "telegramShares"',
|
||||||
|
'article.views as "views"',
|
||||||
|
'article.createdAt as "createdAt"',
|
||||||
|
'article.updatedAt as "updatedAt"',
|
||||||
|
])
|
||||||
|
.addSelect(
|
||||||
|
`(article.facebookShares + article.twitterShares + article.whatsappShares + article.telegramShares) as "totalShares"`,
|
||||||
|
)
|
||||||
|
.addSelect(
|
||||||
|
`CASE
|
||||||
|
WHEN article.views > 0
|
||||||
|
THEN ROUND(
|
||||||
|
(article.facebookShares + article.twitterShares + article.whatsappShares + 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;
|
||||||
|
whatsappShares: 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 whatsappShares = parseInt(rawResult.whatsappShares) || 0;
|
||||||
|
const telegramShares = parseInt(rawResult.telegramShares) || 0;
|
||||||
|
const views = parseInt(rawResult.views) || 0;
|
||||||
|
const baseTotalShares =
|
||||||
|
facebookShares + twitterShares + whatsappShares + 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,
|
||||||
|
whatsappShares,
|
||||||
|
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;
|
||||||
|
whatsappShares: number;
|
||||||
|
telegramShares: number;
|
||||||
|
linkShares: number;
|
||||||
|
}> {
|
||||||
|
interface ArticleStatsRaw {
|
||||||
|
facebookShares: string;
|
||||||
|
twitterShares: string;
|
||||||
|
whatsappShares: string;
|
||||||
|
telegramShares: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const articleStats = (await this.articleRepository
|
||||||
|
.createQueryBuilder('article')
|
||||||
|
.select([
|
||||||
|
'SUM(article.facebookShares) as facebookShares',
|
||||||
|
'SUM(article.twitterShares) as twitterShares',
|
||||||
|
'SUM(article.whatsappShares) as whatsappShares',
|
||||||
|
'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 whatsappShares = parseInt(articleStats?.whatsappShares || '0') || 0;
|
||||||
|
const telegramShares = parseInt(articleStats?.telegramShares || '0') || 0;
|
||||||
|
|
||||||
|
const totalShares =
|
||||||
|
facebookShares +
|
||||||
|
twitterShares +
|
||||||
|
whatsappShares +
|
||||||
|
telegramShares +
|
||||||
|
linkShares;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalShares,
|
||||||
|
facebookShares,
|
||||||
|
twitterShares,
|
||||||
|
whatsappShares,
|
||||||
|
telegramShares,
|
||||||
|
linkShares,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,11 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Delete,
|
Delete,
|
||||||
|
Patch,
|
||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
ValidationPipe,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ArticlesService } from './articles.service';
|
import { ArticlesService } from './articles.service';
|
||||||
import {
|
import {
|
||||||
@@ -16,42 +17,72 @@ import {
|
|||||||
FindArticlesDto,
|
FindArticlesDto,
|
||||||
} from './articles.dto';
|
} 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')
|
@Controller('articles')
|
||||||
export class ArticlesController {
|
export class ArticlesController {
|
||||||
constructor(private readonly articlesService: ArticlesService) {}
|
constructor(private readonly articlesService: ArticlesService) {}
|
||||||
|
|
||||||
@Post()
|
@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);
|
return this.articlesService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
findAll(
|
@Public()
|
||||||
@Query(new ValidationPipe({ transform: true })) dto: FindArticlesDto,
|
findAll(@Query() dto: FindArticlesDto) {
|
||||||
) {
|
|
||||||
return this.articlesService.findAll(dto);
|
return this.articlesService.findAll(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('hero')
|
||||||
|
@Public()
|
||||||
|
findHero() {
|
||||||
|
return this.articlesService.findHeroArticle();
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@Public()
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.articlesService.findOne(id);
|
return this.articlesService.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('slug/:slug')
|
@Get('slug/:slug')
|
||||||
|
@Public()
|
||||||
findBySlug(@Param('slug') slug: string) {
|
findBySlug(@Param('slug') slug: string) {
|
||||||
return this.articlesService.findBySlug(slug);
|
return this.articlesService.findBySlug(slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
update(
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Param('id') id: string,
|
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||||
@Body(new ValidationPipe({ transform: true })) dto: UpdateArticleDto,
|
update(@Param('id') id: string, @Body() dto: UpdateArticleDto) {
|
||||||
) {
|
|
||||||
return this.articlesService.update(id, dto);
|
return this.articlesService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.articlesService.remove(id);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,9 +6,15 @@ import {
|
|||||||
IsUUID,
|
IsUUID,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsDate,
|
IsDateString,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { ArticleStatus, LiveBlogStatus } from './entities';
|
import {
|
||||||
|
ArticleStatus,
|
||||||
|
LiveBlogStatus,
|
||||||
|
ImagePosition,
|
||||||
|
ImageSize,
|
||||||
|
VideoPosition,
|
||||||
|
} from './entities';
|
||||||
|
|
||||||
export class CreateArticleDto {
|
export class CreateArticleDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -42,6 +48,10 @@ export class CreateArticleDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
strapiId?: string;
|
strapiId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isHero?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
authorId?: string;
|
authorId?: string;
|
||||||
@@ -49,6 +59,50 @@ export class CreateArticleDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
categoryId?: string;
|
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 {
|
export class UpdateArticleDto {
|
||||||
@@ -85,6 +139,10 @@ export class UpdateArticleDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
strapiId?: string;
|
strapiId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isHero?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
authorId?: string;
|
authorId?: string;
|
||||||
@@ -92,6 +150,50 @@ export class UpdateArticleDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
categoryId?: string;
|
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 {
|
export class FindArticlesDto {
|
||||||
@@ -108,8 +210,37 @@ export class FindArticlesDto {
|
|||||||
tag?: string;
|
tag?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(ArticleStatus)
|
@IsString()
|
||||||
status?: ArticleStatus;
|
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()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -153,6 +284,30 @@ export class CreateLiveBlogDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
categoryId?: string;
|
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 {
|
export class UpdateLiveBlogDto {
|
||||||
@@ -187,6 +342,30 @@ export class UpdateLiveBlogDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
categoryId?: string;
|
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 {
|
export class CreateLiveBlogUpdateDto {
|
||||||
@@ -198,16 +377,24 @@ export class CreateLiveBlogUpdateDto {
|
|||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsString()
|
||||||
authorId?: string;
|
authorId?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDate()
|
@IsDateString()
|
||||||
scheduledAt?: Date;
|
scheduledAt?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
strapiId?: string;
|
image?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
videoUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
authorName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateLiveBlogUpdateDto {
|
export class UpdateLiveBlogUpdateDto {
|
||||||
@@ -220,43 +407,18 @@ export class UpdateLiveBlogUpdateDto {
|
|||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsString()
|
||||||
authorId?: string;
|
authorId?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDate()
|
@IsString()
|
||||||
scheduledAt?: Date;
|
image?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
strapiId?: string;
|
videoUrl?: string;
|
||||||
}
|
|
||||||
|
|
||||||
export class FindLiveBlogsDto {
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
category?: string;
|
authorName?: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
author?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
status?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
isPinned?: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
search?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
page?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
limit?: number;
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsEnum,
|
||||||
|
IsArray,
|
||||||
|
IsUUID,
|
||||||
|
IsNumber,
|
||||||
|
IsBoolean,
|
||||||
|
IsDate,
|
||||||
|
} from 'class-validator';
|
||||||
|
import {
|
||||||
|
ArticleStatus,
|
||||||
|
LiveBlogStatus,
|
||||||
|
ImagePosition,
|
||||||
|
ImageSize,
|
||||||
|
VideoPosition,
|
||||||
|
} from './entities';
|
||||||
|
|
||||||
|
export class CreateArticleDto {
|
||||||
|
@IsString()
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
excerpt?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
slug?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
featuredImage?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ArticleStatus)
|
||||||
|
status?: ArticleStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
strapiId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
authorId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ImagePosition)
|
||||||
|
imagePosition?: ImagePosition;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ImageSize)
|
||||||
|
imageSize?: ImageSize;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
videoUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(VideoPosition)
|
||||||
|
videoPosition?: VideoPosition;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
videoCaption?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateArticleDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
content?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
excerpt?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
slug?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
featuredImage?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ArticleStatus)
|
||||||
|
status?: ArticleStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
strapiId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
authorId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ImagePosition)
|
||||||
|
imagePosition?: ImagePosition;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ImageSize)
|
||||||
|
imageSize?: ImageSize;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
videoUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(VideoPosition)
|
||||||
|
videoPosition?: VideoPosition;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
videoCaption?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FindArticlesDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
author?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
tag?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateLiveBlogDto {
|
||||||
|
@IsString()
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
slug: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(LiveBlogStatus)
|
||||||
|
status?: LiveBlogStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isPinned?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
strapiId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
authorId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
featuredImage?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ImagePosition)
|
||||||
|
imagePosition?: ImagePosition;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ImageSize)
|
||||||
|
imageSize?: ImageSize;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
videoUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(VideoPosition)
|
||||||
|
videoPosition?: VideoPosition;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
videoCaption?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateLiveBlogDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
slug?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(LiveBlogStatus)
|
||||||
|
status?: LiveBlogStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isPinned?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
strapiId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
authorId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
featuredImage?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ImagePosition)
|
||||||
|
imagePosition?: ImagePosition;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ImageSize)
|
||||||
|
imageSize?: ImageSize;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
videoUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(VideoPosition)
|
||||||
|
videoPosition?: VideoPosition;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
videoCaption?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateLiveBlogUpdateDto {
|
||||||
|
@IsString()
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isPinned?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
authorId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDate()
|
||||||
|
scheduledAt?: Date;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
strapiId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateLiveBlogUpdateDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
content?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isPinned?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
authorId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDate()
|
||||||
|
scheduledAt?: Date;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
strapiId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FindLiveBlogsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
author?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isPinned?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ArticlesService } from './articles.service';
|
import { ArticlesService } from './articles.service';
|
||||||
import { ArticlesController } from './articles.controller';
|
import { ArticlesController } from './articles.controller';
|
||||||
import { Article, Author, Category } from './entities';
|
import { Article, Author, Category } from './entities';
|
||||||
|
import { StrapiModule } from './strapi.module';
|
||||||
|
import { PushModule } from './push/push.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Article, Author, Category])],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Article, Author, Category]),
|
||||||
|
forwardRef(() => StrapiModule),
|
||||||
|
forwardRef(() => PushModule),
|
||||||
|
],
|
||||||
controllers: [ArticlesController],
|
controllers: [ArticlesController],
|
||||||
providers: [ArticlesService],
|
providers: [ArticlesService],
|
||||||
exports: [ArticlesService],
|
exports: [ArticlesService],
|
||||||
|
|||||||
@@ -1,18 +1,34 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
Logger,
|
||||||
|
Inject,
|
||||||
|
forwardRef,
|
||||||
|
Optional,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { Article, ArticleStatus } from './entities';
|
import { Article, ArticleStatus } from './entities';
|
||||||
|
import { StrapiService } from './strapi.service';
|
||||||
import {
|
import {
|
||||||
CreateArticleDto,
|
CreateArticleDto,
|
||||||
UpdateArticleDto,
|
UpdateArticleDto,
|
||||||
FindArticlesDto,
|
FindArticlesDto,
|
||||||
} from './articles.dto';
|
} from './articles.dto';
|
||||||
|
import { PushService } from './push/push.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ArticlesService {
|
export class ArticlesService {
|
||||||
|
private readonly logger = new Logger(ArticlesService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Article)
|
@InjectRepository(Article)
|
||||||
private readonly articleRepository: Repository<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> {
|
async create(dto: CreateArticleDto): Promise<Article> {
|
||||||
@@ -26,21 +42,23 @@ export class ArticlesService {
|
|||||||
async findAll(
|
async findAll(
|
||||||
dto: FindArticlesDto,
|
dto: FindArticlesDto,
|
||||||
): Promise<{ data: Article[]; total: number }> {
|
): Promise<{ data: Article[]; total: number }> {
|
||||||
const {
|
const { category, author, tag, status, search, page = 1, limit = 10 } = dto;
|
||||||
category,
|
|
||||||
author,
|
|
||||||
tag,
|
|
||||||
status = ArticleStatus.PUBLISHED,
|
|
||||||
search,
|
|
||||||
page = 1,
|
|
||||||
limit = 10,
|
|
||||||
} = dto;
|
|
||||||
|
|
||||||
const queryBuilder = this.articleRepository
|
const queryBuilder = this.articleRepository
|
||||||
.createQueryBuilder('article')
|
.createQueryBuilder('article')
|
||||||
.leftJoinAndSelect('article.author', 'author')
|
.leftJoinAndSelect('article.author', 'author')
|
||||||
.leftJoinAndSelect('article.category', 'category')
|
.leftJoinAndSelect('article.category', 'category');
|
||||||
.where('article.status = :status', { status });
|
|
||||||
|
// 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) {
|
if (category) {
|
||||||
queryBuilder.andWhere('category.slug = :category', { category });
|
queryBuilder.andWhere('category.slug = :category', { category });
|
||||||
@@ -98,31 +116,213 @@ export class ArticlesService {
|
|||||||
|
|
||||||
async update(id: string, dto: UpdateArticleDto): Promise<Article> {
|
async update(id: string, dto: UpdateArticleDto): Promise<Article> {
|
||||||
const article = await this.findOne(id);
|
const article = await this.findOne(id);
|
||||||
|
const oldStatus = article.status;
|
||||||
Object.assign(article, dto);
|
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> {
|
async remove(id: string): Promise<void> {
|
||||||
const article = await this.findOne(id);
|
const article = await this.findOne(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);
|
await this.articleRepository.remove(article);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async archive(id: string): Promise<Article> {
|
||||||
|
const article = await this.findOne(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.findOne(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(
|
async syncFromStrapi(
|
||||||
strapiId: string,
|
strapiId: string,
|
||||||
data: Partial<CreateArticleDto>,
|
data: Partial<CreateArticleDto>,
|
||||||
): Promise<Article> {
|
): 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) {
|
if (!article) {
|
||||||
article = this.articleRepository.create({
|
this.logger.warn(`Article with strapiId ${strapiId} not found`);
|
||||||
strapiId,
|
return;
|
||||||
...data,
|
|
||||||
status: data.status || ArticleStatus.DRAFT,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
Object.assign(article, data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LocalAuthGuard extends AuthGuard('local') {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { UserRole } from '../entities';
|
||||||
|
|
||||||
|
export const Roles = (...roles: UserRole[]) => SetMetadata('roles', roles);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,37 @@ export enum LiveBlogStatus {
|
|||||||
ARCHIVED = 'archived',
|
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')
|
@Entity('authors')
|
||||||
export class Author {
|
export class Author {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
@@ -94,6 +125,36 @@ export class Category {
|
|||||||
parent: 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')
|
@Entity('articles')
|
||||||
export class Article {
|
export class Article {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
@@ -114,6 +175,30 @@ export class Article {
|
|||||||
@Column({ default: '' })
|
@Column({ default: '' })
|
||||||
featuredImage: string;
|
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({
|
@Column({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
default: '[]',
|
default: '[]',
|
||||||
@@ -130,7 +215,7 @@ export class Article {
|
|||||||
@Column({ default: 0 })
|
@Column({ default: 0 })
|
||||||
views: number;
|
views: number;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, unique: true })
|
||||||
strapiId: string;
|
strapiId: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
@@ -139,6 +224,39 @@ export class Article {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
categoryId: string;
|
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 })
|
||||||
|
whatsappShares: number;
|
||||||
|
|
||||||
|
@Column({ default: 0 })
|
||||||
|
telegramShares: number;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
isHero: boolean;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@@ -152,6 +270,12 @@ export class Article {
|
|||||||
@ManyToOne(() => Category, { onDelete: 'SET NULL' })
|
@ManyToOne(() => Category, { onDelete: 'SET NULL' })
|
||||||
@JoinColumn({ name: 'categoryId' })
|
@JoinColumn({ name: 'categoryId' })
|
||||||
category: Category;
|
category: Category;
|
||||||
|
|
||||||
|
@OneToMany(() => Comment, (comment) => comment.article)
|
||||||
|
comments: Comment[];
|
||||||
|
|
||||||
|
@OneToMany(() => Reaction, (reaction) => reaction.article)
|
||||||
|
reactions: Reaction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity('live_blogs')
|
@Entity('live_blogs')
|
||||||
@@ -177,7 +301,7 @@ export class LiveBlog {
|
|||||||
@Column({ default: false })
|
@Column({ default: false })
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, unique: true })
|
||||||
strapiId: string;
|
strapiId: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
@@ -186,6 +310,33 @@ export class LiveBlog {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
categoryId: string;
|
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 })
|
@Column({ default: 0 })
|
||||||
viewCount: number;
|
viewCount: number;
|
||||||
|
|
||||||
@@ -207,6 +358,12 @@ export class LiveBlog {
|
|||||||
cascade: true,
|
cascade: true,
|
||||||
})
|
})
|
||||||
updates: LiveBlogUpdate[];
|
updates: LiveBlogUpdate[];
|
||||||
|
|
||||||
|
@OneToMany(() => Comment, (comment) => comment.liveBlog)
|
||||||
|
comments: Comment[];
|
||||||
|
|
||||||
|
@OneToMany(() => Reaction, (reaction) => reaction.liveBlog)
|
||||||
|
reactions: Reaction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity('live_blog_updates')
|
@Entity('live_blog_updates')
|
||||||
@@ -243,3 +400,100 @@ export class LiveBlogUpdate {
|
|||||||
@JoinColumn({ name: 'liveBlogId' })
|
@JoinColumn({ name: 'liveBlogId' })
|
||||||
liveBlog: LiveBlog;
|
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;
|
||||||
|
}
|
||||||
@@ -4,13 +4,14 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Delete,
|
Delete,
|
||||||
|
Patch,
|
||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
ValidationPipe,
|
|
||||||
Res,
|
Res,
|
||||||
Logger,
|
Logger,
|
||||||
Headers,
|
Headers,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { Response as ExpressResponse } from 'express';
|
import type { Response as ExpressResponse } from 'express';
|
||||||
import { LiveBlogService } from './live-blog.service';
|
import { LiveBlogService } from './live-blog.service';
|
||||||
@@ -21,6 +22,11 @@ import {
|
|||||||
CreateLiveBlogUpdateDto,
|
CreateLiveBlogUpdateDto,
|
||||||
UpdateLiveBlogUpdateDto,
|
UpdateLiveBlogUpdateDto,
|
||||||
} from './articles.dto';
|
} 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')
|
@Controller('live-blogs')
|
||||||
export class LiveBlogController {
|
export class LiveBlogController {
|
||||||
@@ -30,68 +36,76 @@ export class LiveBlogController {
|
|||||||
|
|
||||||
// Live Blog CRUD operations
|
// Live Blog CRUD operations
|
||||||
@Get('featured')
|
@Get('featured')
|
||||||
|
@Public()
|
||||||
getFeatured() {
|
getFeatured() {
|
||||||
this.logger.log('GET /featured called');
|
this.logger.log('GET /featured called');
|
||||||
return this.liveBlogService.findPinned();
|
return this.liveBlogService.findPinned();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('active')
|
@Get('active')
|
||||||
|
@Public()
|
||||||
getActive() {
|
getActive() {
|
||||||
return this.liveBlogService.findActive();
|
return this.liveBlogService.findActive();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('recent')
|
@Get('recent')
|
||||||
|
@Public()
|
||||||
getRecent() {
|
getRecent() {
|
||||||
return this.liveBlogService.getLiveBlogsWithRecentUpdates();
|
return this.liveBlogService.getLiveBlogsWithRecentUpdates();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
create(
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Body(new ValidationPipe({ transform: true })) dto: CreateLiveBlogDto,
|
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||||
) {
|
create(@Body() dto: CreateLiveBlogDto) {
|
||||||
return this.liveBlogService.create(dto);
|
return this.liveBlogService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
findAll(
|
@Public()
|
||||||
@Query(new ValidationPipe({ transform: true })) dto: FindLiveBlogsDto,
|
findAll(@Query() dto: FindLiveBlogsDto) {
|
||||||
) {
|
|
||||||
return this.liveBlogService.findAll(dto);
|
return this.liveBlogService.findAll(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@Public()
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.liveBlogService.findOne(id);
|
return this.liveBlogService.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('slug/:slug')
|
@Get('slug/:slug')
|
||||||
|
@Public()
|
||||||
findBySlug(@Param('slug') slug: string) {
|
findBySlug(@Param('slug') slug: string) {
|
||||||
return this.liveBlogService.findBySlug(slug);
|
return this.liveBlogService.findBySlug(slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
update(
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Param('id') id: string,
|
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||||
@Body(new ValidationPipe({ transform: true })) dto: UpdateLiveBlogDto,
|
update(@Param('id') id: string, @Body() dto: UpdateLiveBlogDto) {
|
||||||
) {
|
|
||||||
return this.liveBlogService.update(id, dto);
|
return this.liveBlogService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.liveBlogService.remove(id);
|
return this.liveBlogService.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live Blog Updates CRUD operations
|
// Live Blog Updates CRUD operations
|
||||||
@Post(':id/updates')
|
@Post(':id/updates')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||||
createUpdate(
|
createUpdate(
|
||||||
@Param('id') liveBlogId: string,
|
@Param('id') liveBlogId: string,
|
||||||
@Body(new ValidationPipe({ transform: true })) dto: CreateLiveBlogUpdateDto,
|
@Body() dto: CreateLiveBlogUpdateDto,
|
||||||
) {
|
) {
|
||||||
return this.liveBlogService.createUpdate(dto, liveBlogId);
|
return this.liveBlogService.createUpdate(dto, liveBlogId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/updates')
|
@Get(':id/updates')
|
||||||
|
@Public()
|
||||||
findUpdates(
|
findUpdates(
|
||||||
@Param('id') liveBlogId: string,
|
@Param('id') liveBlogId: string,
|
||||||
@Query('page') page = 1,
|
@Query('page') page = 1,
|
||||||
@@ -105,15 +119,19 @@ export class LiveBlogController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id/updates/:updateId')
|
@Put(':id/updates/:updateId')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||||
updateUpdate(
|
updateUpdate(
|
||||||
@Param('id') liveBlogId: string,
|
@Param('id') liveBlogId: string,
|
||||||
@Param('updateId') updateId: string,
|
@Param('updateId') updateId: string,
|
||||||
@Body(new ValidationPipe({ transform: true })) dto: UpdateLiveBlogUpdateDto,
|
@Body() dto: UpdateLiveBlogUpdateDto,
|
||||||
) {
|
) {
|
||||||
return this.liveBlogService.updateUpdate(liveBlogId, updateId, dto);
|
return this.liveBlogService.updateUpdate(liveBlogId, updateId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id/updates/:updateId')
|
@Delete(':id/updates/:updateId')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
|
||||||
removeUpdate(
|
removeUpdate(
|
||||||
@Param('id') liveBlogId: string,
|
@Param('id') liveBlogId: string,
|
||||||
@Param('updateId') updateId: string,
|
@Param('updateId') updateId: string,
|
||||||
@@ -121,8 +139,23 @@ export class LiveBlogController {
|
|||||||
return this.liveBlogService.removeUpdate(liveBlogId, updateId);
|
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
|
// SSE endpoint for real-time updates
|
||||||
@Get(':id/stream')
|
@Get(':id/stream')
|
||||||
|
@Public()
|
||||||
stream(
|
stream(
|
||||||
@Param('id') liveBlogId: string,
|
@Param('id') liveBlogId: string,
|
||||||
@Res() response: ExpressResponse,
|
@Res() response: ExpressResponse,
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
import { LiveBlogService } from './live-blog.service';
|
import { LiveBlogService } from './live-blog.service';
|
||||||
import { LiveBlogController } from './live-blog.controller';
|
import { LiveBlogController } from './live-blog.controller';
|
||||||
import { LiveBlog, LiveBlogUpdate, Author, Category } from './entities';
|
import { LiveBlog, LiveBlogUpdate, Author, Category } from './entities';
|
||||||
|
import { StrapiModule } from './strapi.module';
|
||||||
|
import { PushModule } from './push/push.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([LiveBlog, LiveBlogUpdate, Author, Category]),
|
TypeOrmModule.forFeature([LiveBlog, LiveBlogUpdate, Author, Category]),
|
||||||
EventEmitterModule.forRoot(),
|
EventEmitterModule.forRoot(),
|
||||||
|
forwardRef(() => StrapiModule),
|
||||||
|
forwardRef(() => PushModule),
|
||||||
],
|
],
|
||||||
controllers: [LiveBlogController],
|
controllers: [LiveBlogController],
|
||||||
providers: [LiveBlogService],
|
providers: [LiveBlogService],
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
Logger,
|
Logger,
|
||||||
OnModuleInit,
|
OnModuleInit,
|
||||||
|
Inject,
|
||||||
|
forwardRef,
|
||||||
|
Optional,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, In } from 'typeorm';
|
import { Repository, In } from 'typeorm';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { LiveBlog, LiveBlogUpdate, LiveBlogStatus } from './entities';
|
import { LiveBlog, LiveBlogUpdate, LiveBlogStatus } from './entities';
|
||||||
|
import { StrapiService } from './strapi.service';
|
||||||
import {
|
import {
|
||||||
CreateLiveBlogDto,
|
CreateLiveBlogDto,
|
||||||
UpdateLiveBlogDto,
|
UpdateLiveBlogDto,
|
||||||
@@ -16,6 +20,7 @@ import {
|
|||||||
CreateLiveBlogUpdateDto,
|
CreateLiveBlogUpdateDto,
|
||||||
UpdateLiveBlogUpdateDto,
|
UpdateLiveBlogUpdateDto,
|
||||||
} from './articles.dto';
|
} from './articles.dto';
|
||||||
|
import { PushService } from './push/push.service';
|
||||||
|
|
||||||
interface SseClient {
|
interface SseClient {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -50,6 +55,11 @@ export class LiveBlogService implements OnModuleInit {
|
|||||||
@InjectRepository(LiveBlogUpdate)
|
@InjectRepository(LiveBlogUpdate)
|
||||||
private readonly liveBlogUpdateRepository: Repository<LiveBlogUpdate>,
|
private readonly liveBlogUpdateRepository: Repository<LiveBlogUpdate>,
|
||||||
private readonly eventEmitter: EventEmitter2,
|
private readonly eventEmitter: EventEmitter2,
|
||||||
|
@Inject(forwardRef(() => StrapiService))
|
||||||
|
private readonly strapiService: StrapiService,
|
||||||
|
@Optional()
|
||||||
|
@Inject(forwardRef(() => PushService))
|
||||||
|
private readonly pushService?: PushService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
@@ -116,13 +126,9 @@ export class LiveBlogService implements OnModuleInit {
|
|||||||
} else {
|
} else {
|
||||||
queryBuilder.where('liveBlog.status = :status', { status });
|
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) {
|
if (category) {
|
||||||
queryBuilder.andWhere('category.slug = :category', { category });
|
queryBuilder.andWhere('category.slug = :category', { category });
|
||||||
@@ -252,6 +258,22 @@ export class LiveBlogService implements OnModuleInit {
|
|||||||
blogId: id,
|
blogId: id,
|
||||||
status: dto.status,
|
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;
|
return updatedBlog;
|
||||||
@@ -259,9 +281,74 @@ export class LiveBlogService implements OnModuleInit {
|
|||||||
|
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
const liveBlog = await this.findOne(id);
|
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);
|
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
|
// Live Blog Update CRUD operations
|
||||||
async createUpdate(
|
async createUpdate(
|
||||||
dto: CreateLiveBlogUpdateDto,
|
dto: CreateLiveBlogUpdateDto,
|
||||||
@@ -282,6 +369,22 @@ export class LiveBlogService implements OnModuleInit {
|
|||||||
update: savedUpdate,
|
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;
|
return savedUpdate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,7 +483,7 @@ export class LiveBlogService implements OnModuleInit {
|
|||||||
const keepAliveInterval = setInterval(() => {
|
const keepAliveInterval = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
response.write(`: keep-alive\n\n`);
|
response.write(`: keep-alive\n\n`);
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Client disconnected, stop sending keep-alive
|
// Client disconnected, stop sending keep-alive
|
||||||
clearInterval(keepAliveInterval);
|
clearInterval(keepAliveInterval);
|
||||||
}
|
}
|
||||||
@@ -425,21 +528,91 @@ export class LiveBlogService implements OnModuleInit {
|
|||||||
strapiId: string,
|
strapiId: string,
|
||||||
data: Partial<CreateLiveBlogDto>,
|
data: Partial<CreateLiveBlogDto>,
|
||||||
): Promise<LiveBlog> {
|
): 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 },
|
where: { strapiId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!liveBlog) {
|
if (!liveBlog) {
|
||||||
liveBlog = this.liveBlogRepository.create({
|
this.logger.warn(`LiveBlog with strapiId ${strapiId} not found`);
|
||||||
strapiId,
|
return;
|
||||||
...data,
|
|
||||||
status: data.status || LiveBlogStatus.DRAFT,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
Object.assign(liveBlog, data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.liveBlogRepository.save(liveBlog);
|
await this.liveBlogRepository.remove(liveBlog);
|
||||||
|
this.logger.log(
|
||||||
|
`Successfully deleted live blog with strapiId: ${strapiId}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility methods
|
// Utility methods
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import * as webpush from 'web-push';
|
||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { PushSubscriptionEntity } from './push-subscription.entity';
|
||||||
|
import { SubscribeDto, UnsubscribeDto } from './push.dto';
|
||||||
|
|
||||||
|
export interface PushPayload {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
icon?: string;
|
||||||
|
badge?: string;
|
||||||
|
url?: string;
|
||||||
|
tag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PushService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(PushService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
@InjectRepository(PushSubscriptionEntity)
|
||||||
|
private subscriptionRepo: Repository<PushSubscriptionEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
const publicKey = this.configService.get<string>('VAPID_PUBLIC_KEY');
|
||||||
|
const privateKey = this.configService.get<string>('VAPID_PRIVATE_KEY');
|
||||||
|
const subject = this.configService.get<string>(
|
||||||
|
'VAPID_SUBJECT',
|
||||||
|
'mailto:contact@placebo.mk',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!publicKey || !privateKey) {
|
||||||
|
this.logger.warn(
|
||||||
|
'VAPID keys not configured. Push notifications will not work. Run: npm run generate-vapid',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
webpush.setVapidDetails(subject, publicKey, privateKey);
|
||||||
|
this.logger.log('VAPID keys configured successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
getPublicKey(): string | null {
|
||||||
|
return this.configService.get<string>('VAPID_PUBLIC_KEY') ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribe(dto: SubscribeDto): Promise<PushSubscriptionEntity> {
|
||||||
|
const existing = await this.subscriptionRepo.findOne({
|
||||||
|
where: { endpoint: dto.endpoint },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (dto.userId && existing.userId !== dto.userId) {
|
||||||
|
existing.userId = dto.userId;
|
||||||
|
return this.subscriptionRepo.save(existing);
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = this.subscriptionRepo.create({
|
||||||
|
endpoint: dto.endpoint,
|
||||||
|
p256dh: dto.p256dh,
|
||||||
|
auth: dto.auth,
|
||||||
|
userId: dto.userId ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.subscriptionRepo.save(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
async unsubscribe(dto: UnsubscribeDto): Promise<void> {
|
||||||
|
await this.subscriptionRepo.delete({ endpoint: dto.endpoint });
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendToAll(
|
||||||
|
payload: PushPayload,
|
||||||
|
): Promise<{ sent: number; failed: number }> {
|
||||||
|
const subscriptions = await this.subscriptionRepo.find();
|
||||||
|
|
||||||
|
if (subscriptions.length === 0) {
|
||||||
|
return { sent: 0, failed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const notification = JSON.stringify({
|
||||||
|
title: payload.title,
|
||||||
|
body: payload.body,
|
||||||
|
icon: payload.icon ?? '/icons/icon-192.png',
|
||||||
|
badge: payload.badge ?? '/icons/badge-72.png',
|
||||||
|
url: payload.url ?? '/',
|
||||||
|
tag: payload.tag,
|
||||||
|
});
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
subscriptions.map(async (sub) => {
|
||||||
|
try {
|
||||||
|
await webpush.sendNotification(
|
||||||
|
{
|
||||||
|
endpoint: sub.endpoint,
|
||||||
|
keys: {
|
||||||
|
p256dh: sub.p256dh,
|
||||||
|
auth: sub.auth,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
notification,
|
||||||
|
{
|
||||||
|
TTL: 86400,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { success: true, id: sub.id };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
this.logger.warn(`Push failed for ${sub.id}: ${error.message}`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
error instanceof webpush.WebPushError &&
|
||||||
|
(error.statusCode === 410 || error.statusCode === 404)
|
||||||
|
) {
|
||||||
|
await this.subscriptionRepo.delete({ id: sub.id });
|
||||||
|
this.logger.log(`Removed invalid subscription: ${sub.id}`);
|
||||||
|
}
|
||||||
|
return { success: false, id: sub.id };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === 'fulfilled' && result.value.success) {
|
||||||
|
sent++;
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Push sent: ${sent}, failed: ${failed}`);
|
||||||
|
return { sent, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
async notifyNewArticle(
|
||||||
|
articleTitle: string,
|
||||||
|
articleSlug: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.sendToAll({
|
||||||
|
title: 'Нови вести! 📰',
|
||||||
|
body: articleTitle,
|
||||||
|
url: `/article/${articleSlug}`,
|
||||||
|
tag: 'new-article',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async notifyLiveBlogUpdate(
|
||||||
|
blogTitle: string,
|
||||||
|
blogSlug: string,
|
||||||
|
updatePreview: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const body =
|
||||||
|
updatePreview.length > 100
|
||||||
|
? updatePreview.substring(0, 97) + '...'
|
||||||
|
: updatePreview;
|
||||||
|
|
||||||
|
await this.sendToAll({
|
||||||
|
title: `${blogTitle} 📡`,
|
||||||
|
body,
|
||||||
|
url: `/live-blog/${blogSlug}`,
|
||||||
|
tag: `live-blog-${blogSlug}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStats(): Promise<{ totalSubscribers: number }> {
|
||||||
|
const totalSubscribers = await this.subscriptionRepo.count();
|
||||||
|
return { totalSubscribers };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
import { Controller, Post, Body, Logger } from '@nestjs/common';
|
import { Controller, Post, Get, Body, Logger } from '@nestjs/common';
|
||||||
import { StrapiService } from './strapi.service';
|
import { StrapiService } from './strapi.service';
|
||||||
|
import { Public } from './auth/public.decorator';
|
||||||
|
|
||||||
interface WebhookBody {
|
interface WebhookBody {
|
||||||
event: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish';
|
event:
|
||||||
|
| 'entry.create'
|
||||||
|
| 'entry.update'
|
||||||
|
| 'entry.delete'
|
||||||
|
| 'entry.publish'
|
||||||
|
| 'entry.unpublish';
|
||||||
model: string;
|
model: string;
|
||||||
entry: {
|
entry: {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -16,6 +22,7 @@ export class StrapiController {
|
|||||||
constructor(private readonly strapiService: StrapiService) {}
|
constructor(private readonly strapiService: StrapiService) {}
|
||||||
|
|
||||||
@Post('article')
|
@Post('article')
|
||||||
|
@Public()
|
||||||
async handleArticleWebhook(@Body() body: WebhookBody) {
|
async handleArticleWebhook(@Body() body: WebhookBody) {
|
||||||
this.logger.log(`Received article webhook: ${JSON.stringify(body)}`);
|
this.logger.log(`Received article webhook: ${JSON.stringify(body)}`);
|
||||||
const { event, model, entry } = body;
|
const { event, model, entry } = body;
|
||||||
@@ -33,6 +40,7 @@ export class StrapiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('live-blog')
|
@Post('live-blog')
|
||||||
|
@Public()
|
||||||
async handleLiveBlogWebhook(@Body() body: WebhookBody) {
|
async handleLiveBlogWebhook(@Body() body: WebhookBody) {
|
||||||
this.logger.log(`Received live-blog webhook: ${JSON.stringify(body)}`);
|
this.logger.log(`Received live-blog webhook: ${JSON.stringify(body)}`);
|
||||||
const { event, model, entry } = body;
|
const { event, model, entry } = body;
|
||||||
@@ -50,6 +58,7 @@ export class StrapiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@Public()
|
||||||
async handleGenericWebhook(@Body() body: unknown) {
|
async handleGenericWebhook(@Body() body: unknown) {
|
||||||
this.logger.log(`Received generic webhook: ${JSON.stringify(body)}`);
|
this.logger.log(`Received generic webhook: ${JSON.stringify(body)}`);
|
||||||
|
|
||||||
@@ -91,6 +100,7 @@ export class StrapiController {
|
|||||||
'entry.update',
|
'entry.update',
|
||||||
'entry.delete',
|
'entry.delete',
|
||||||
'entry.publish',
|
'entry.publish',
|
||||||
|
'entry.unpublish',
|
||||||
];
|
];
|
||||||
if (!validEvents.includes(event)) {
|
if (!validEvents.includes(event)) {
|
||||||
this.logger.warn(`Invalid event type: ${event}`);
|
this.logger.warn(`Invalid event type: ${event}`);
|
||||||
@@ -102,25 +112,36 @@ export class StrapiController {
|
|||||||
| 'entry.create'
|
| 'entry.create'
|
||||||
| 'entry.update'
|
| 'entry.update'
|
||||||
| 'entry.delete'
|
| 'entry.delete'
|
||||||
| 'entry.publish',
|
| 'entry.publish'
|
||||||
|
| 'entry.unpublish',
|
||||||
{ documentId, model },
|
{ documentId, model },
|
||||||
);
|
);
|
||||||
return { message: 'Webhook processed successfully' };
|
return { message: 'Webhook processed successfully' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('sync/all')
|
@Post('sync/all')
|
||||||
|
@Public()
|
||||||
async syncAllArticles() {
|
async syncAllArticles() {
|
||||||
await this.strapiService.syncArticles();
|
await this.strapiService.syncArticles();
|
||||||
return { message: 'Articles sync completed' };
|
return { message: 'Articles sync completed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('sync/all')
|
||||||
|
@Public()
|
||||||
|
async syncAllArticlesGet() {
|
||||||
|
await this.strapiService.syncArticles();
|
||||||
|
return { message: 'Articles sync completed' };
|
||||||
|
}
|
||||||
|
|
||||||
@Post('sync/live-blogs')
|
@Post('sync/live-blogs')
|
||||||
|
@Public()
|
||||||
async syncAllLiveBlogs() {
|
async syncAllLiveBlogs() {
|
||||||
await this.strapiService.syncLiveBlogs();
|
await this.strapiService.syncLiveBlogs();
|
||||||
return { message: 'Live blogs sync completed' };
|
return { message: 'Live blogs sync completed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('sync/everything')
|
@Post('sync/everything')
|
||||||
|
@Public()
|
||||||
async syncEverything() {
|
async syncEverything() {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.strapiService.syncArticles(),
|
this.strapiService.syncArticles(),
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { HttpModule } from '@nestjs/axios';
|
import { HttpModule } from '@nestjs/axios';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { StrapiService } from './strapi.service';
|
import { StrapiService } from './strapi.service';
|
||||||
import { StrapiController } from './strapi.controller';
|
import { StrapiController } from './strapi.controller';
|
||||||
import { ArticlesModule } from './articles.module';
|
import { ArticlesModule } from './articles.module';
|
||||||
import { LiveBlogModule } from './live-blog.module';
|
import { LiveBlogModule } from './live-blog.module';
|
||||||
|
import { Category } from './entities';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [HttpModule, ArticlesModule, LiveBlogModule],
|
imports: [
|
||||||
|
HttpModule,
|
||||||
|
TypeOrmModule.forFeature([Category]),
|
||||||
|
forwardRef(() => ArticlesModule),
|
||||||
|
forwardRef(() => LiveBlogModule),
|
||||||
|
],
|
||||||
controllers: [StrapiController],
|
controllers: [StrapiController],
|
||||||
providers: [StrapiService],
|
providers: [StrapiService],
|
||||||
exports: [StrapiService],
|
exports: [StrapiService],
|
||||||
|
|||||||
@@ -1,11 +1,35 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from '@nestjs/axios';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
import { lastValueFrom } from 'rxjs';
|
import { lastValueFrom } from 'rxjs';
|
||||||
import { ArticlesService } from './articles.service';
|
import { ArticlesService } from './articles.service';
|
||||||
import { LiveBlogService } from './live-blog.service';
|
import { LiveBlogService } from './live-blog.service';
|
||||||
import { CreateArticleDto, CreateLiveBlogDto } from './articles.dto';
|
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 {
|
interface StrapiArticle {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -17,6 +41,14 @@ interface StrapiArticle {
|
|||||||
publishedAt: string | null;
|
publishedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
img?: StrapiImage;
|
||||||
|
media?: StrapiImage[];
|
||||||
|
imagePosition?: string;
|
||||||
|
imageSize?: string;
|
||||||
|
videoUrl?: string;
|
||||||
|
videoPosition?: string;
|
||||||
|
videoCaption?: string;
|
||||||
|
category?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StrapiLiveBlog {
|
interface StrapiLiveBlog {
|
||||||
@@ -29,6 +61,14 @@ interface StrapiLiveBlog {
|
|||||||
publishedAt: string | null;
|
publishedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
img?: StrapiImage;
|
||||||
|
media?: StrapiImage[];
|
||||||
|
imagePosition?: string;
|
||||||
|
imageSize?: string;
|
||||||
|
videoUrl?: string;
|
||||||
|
videoPosition?: string;
|
||||||
|
videoCaption?: string;
|
||||||
|
category?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StrapiResponse<T> {
|
interface StrapiResponse<T> {
|
||||||
@@ -47,28 +87,136 @@ interface StrapiResponse<T> {
|
|||||||
export class StrapiService {
|
export class StrapiService {
|
||||||
private readonly logger = new Logger(StrapiService.name);
|
private readonly logger = new Logger(StrapiService.name);
|
||||||
private readonly strapiUrl: string;
|
private readonly strapiUrl: string;
|
||||||
|
private readonly strapiPublicUrl: string;
|
||||||
private readonly strapiApiToken: string;
|
private readonly strapiApiToken: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly httpService: HttpService,
|
private readonly httpService: HttpService,
|
||||||
|
@Inject(forwardRef(() => ArticlesService))
|
||||||
private readonly articlesService: ArticlesService,
|
private readonly articlesService: ArticlesService,
|
||||||
|
@Inject(forwardRef(() => LiveBlogService))
|
||||||
private readonly liveBlogService: LiveBlogService,
|
private readonly liveBlogService: LiveBlogService,
|
||||||
|
@InjectRepository(Category)
|
||||||
|
private readonly categoryRepository: Repository<Category>,
|
||||||
) {
|
) {
|
||||||
this.strapiUrl =
|
this.strapiUrl =
|
||||||
this.configService.get<string>('STRAPI_URL') || 'http://localhost:1337';
|
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.strapiApiToken =
|
||||||
this.configService.get<string>('STRAPI_API_TOKEN') || '';
|
this.configService.get<string>('STRAPI_API_TOKEN') || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
private getHeaders() {
|
private getHeaders() {
|
||||||
const headers: Record<string, string> = {
|
// Use public API access - no authentication needed
|
||||||
'Content-Type': 'application/json',
|
// Strapi Public role has been configured to allow article access
|
||||||
};
|
return {};
|
||||||
if (this.strapiApiToken) {
|
}
|
||||||
headers['Authorization'] = `Bearer ${this.strapiApiToken}`;
|
|
||||||
|
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> {
|
async syncArticles(): Promise<void> {
|
||||||
@@ -77,7 +225,7 @@ export class StrapiService {
|
|||||||
|
|
||||||
const response = await lastValueFrom(
|
const response = await lastValueFrom(
|
||||||
this.httpService.get<StrapiResponse<StrapiArticle[]>>(
|
this.httpService.get<StrapiResponse<StrapiArticle[]>>(
|
||||||
`${this.strapiUrl}/api/articles`,
|
`${this.strapiUrl}/api/articles?populate=*`,
|
||||||
{
|
{
|
||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
},
|
},
|
||||||
@@ -88,15 +236,41 @@ export class StrapiService {
|
|||||||
let syncedCount = 0;
|
let syncedCount = 0;
|
||||||
|
|
||||||
for (const strapiArticle of strapiArticles) {
|
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> = {
|
const articleData: Partial<CreateArticleDto> = {
|
||||||
title: strapiArticle.title,
|
title: strapiArticle.title,
|
||||||
excerpt: strapiArticle.description,
|
excerpt: strapiArticle.description || '',
|
||||||
content: strapiArticle.content,
|
content: strapiArticle.content,
|
||||||
slug: strapiArticle.slug,
|
slug,
|
||||||
status: strapiArticle.publishedAt
|
status: strapiArticle.publishedAt
|
||||||
? ArticleStatus.PUBLISHED
|
? ArticleStatus.PUBLISHED
|
||||||
: ArticleStatus.DRAFT,
|
: ArticleStatus.DRAFT,
|
||||||
tags: [],
|
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(
|
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 {
|
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(
|
const response = await lastValueFrom(
|
||||||
this.httpService.get<StrapiResponse<StrapiArticle>>(
|
this.httpService.get<StrapiResponse<StrapiArticle>>(
|
||||||
`${this.strapiUrl}/api/articles/${strapiId}`,
|
`${this.strapiUrl}/api/articles/${strapiId}?populate=*`,
|
||||||
{
|
{
|
||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
},
|
},
|
||||||
@@ -129,28 +313,92 @@ export class StrapiService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const strapiArticle = response.data.data;
|
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> = {
|
const articleData: Partial<CreateArticleDto> = {
|
||||||
title: strapiArticle.title,
|
title: strapiArticle.title,
|
||||||
excerpt: strapiArticle.description,
|
excerpt: strapiArticle.description || '',
|
||||||
content: strapiArticle.content,
|
content: strapiArticle.content,
|
||||||
slug: strapiArticle.slug,
|
slug,
|
||||||
status: strapiArticle.publishedAt
|
status,
|
||||||
? ArticleStatus.PUBLISHED
|
|
||||||
: ArticleStatus.DRAFT,
|
|
||||||
tags: [],
|
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(
|
await this.articlesService.syncFromStrapi(
|
||||||
strapiArticle.documentId,
|
strapiArticle.documentId,
|
||||||
articleData,
|
articleData,
|
||||||
);
|
);
|
||||||
this.logger.log(`Successfully synced article: ${strapiArticle.title}`);
|
this.logger.log(
|
||||||
} catch (error) {
|
`Successfully synced article: ${strapiArticle.title} with status: ${status}`,
|
||||||
this.logger.error(
|
|
||||||
`Failed to sync article ${strapiId} from Strapi`,
|
|
||||||
error,
|
|
||||||
);
|
);
|
||||||
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(
|
const response = await lastValueFrom(
|
||||||
this.httpService.get<StrapiResponse<StrapiLiveBlog[]>>(
|
this.httpService.get<StrapiResponse<StrapiLiveBlog[]>>(
|
||||||
`${this.strapiUrl}/api/live-blogs`,
|
`${this.strapiUrl}/api/live-blogs?populate=*`,
|
||||||
{
|
{
|
||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
},
|
},
|
||||||
@@ -171,11 +419,33 @@ export class StrapiService {
|
|||||||
let syncedCount = 0;
|
let syncedCount = 0;
|
||||||
|
|
||||||
for (const strapiLiveBlog of strapiLiveBlogs) {
|
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> = {
|
const liveBlogData: Partial<CreateLiveBlogDto> = {
|
||||||
title: strapiLiveBlog.title,
|
title: strapiLiveBlog.title,
|
||||||
description: strapiLiveBlog.description,
|
description: strapiLiveBlog.description,
|
||||||
slug: strapiLiveBlog.slug,
|
slug: strapiLiveBlog.slug,
|
||||||
status: this.mapStrapiStatusToLiveBlogStatus(strapiLiveBlog.status),
|
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(
|
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 {
|
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(
|
const response = await lastValueFrom(
|
||||||
this.httpService.get<StrapiResponse<StrapiLiveBlog>>(
|
this.httpService.get<StrapiResponse<StrapiLiveBlog>>(
|
||||||
`${this.strapiUrl}/api/live-blogs/${strapiId}`,
|
`${this.strapiUrl}/api/live-blogs/${strapiId}?populate=*`,
|
||||||
{
|
{
|
||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
},
|
},
|
||||||
@@ -208,24 +488,83 @@ export class StrapiService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const strapiLiveBlog = response.data.data;
|
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> = {
|
const liveBlogData: Partial<CreateLiveBlogDto> = {
|
||||||
title: strapiLiveBlog.title,
|
title: strapiLiveBlog.title,
|
||||||
description: strapiLiveBlog.description,
|
description: strapiLiveBlog.description,
|
||||||
slug: strapiLiveBlog.slug,
|
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(
|
await this.liveBlogService.syncFromStrapi(
|
||||||
strapiLiveBlog.documentId,
|
strapiLiveBlog.documentId,
|
||||||
liveBlogData,
|
liveBlogData,
|
||||||
);
|
);
|
||||||
this.logger.log(`Successfully synced live blog: ${strapiLiveBlog.title}`);
|
this.logger.log(
|
||||||
} catch (error) {
|
`Successfully synced live blog: ${strapiLiveBlog.title} with status: ${status}`,
|
||||||
this.logger.error(
|
|
||||||
`Failed to sync live blog ${strapiId} from Strapi`,
|
|
||||||
error,
|
|
||||||
);
|
);
|
||||||
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(
|
async handleWebhook(
|
||||||
event: 'entry.create' | 'entry.update' | 'entry.delete' | 'entry.publish',
|
event:
|
||||||
|
| 'entry.create'
|
||||||
|
| 'entry.update'
|
||||||
|
| 'entry.delete'
|
||||||
|
| 'entry.publish'
|
||||||
|
| 'entry.unpublish',
|
||||||
data: { documentId: string; model?: string },
|
data: { documentId: string; model?: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -253,17 +597,185 @@ export class StrapiService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (event === 'entry.delete') {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route to appropriate sync method based on model
|
// Route to appropriate sync method based on model
|
||||||
if (data.model === 'article') {
|
if (data.model === 'article') {
|
||||||
await this.syncSingleArticle(data.documentId);
|
await this.syncSingleArticle(data.documentId, event);
|
||||||
} else if (data.model === 'live-blog') {
|
} else if (data.model === 'live-blog') {
|
||||||
await this.syncSingleLiveBlog(data.documentId);
|
await this.syncSingleLiveBlog(data.documentId, event);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`Unknown model type in webhook: ${data.model}`);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
+31
-16
@@ -6,11 +6,10 @@ FROM node:20-alpine AS builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package.json package-lock.json* ./
|
||||||
COPY package-lock.json* ./
|
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN npm ci --only=production
|
RUN npm install --legacy-peer-deps
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -18,40 +17,56 @@ COPY . .
|
|||||||
# Build Strapi
|
# Build Strapi
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# Prune devDependencies after build (suppress warning)
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install su-exec for user switching in entrypoint
|
||||||
|
RUN apk add --no-cache su-exec
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user
|
||||||
RUN addgroup -g 1001 -S nodejs && \
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
adduser -S nodejs -u 1001
|
adduser -S nodejs -u 1001
|
||||||
|
|
||||||
# Install SQLite for development (will use PostgreSQL in production)
|
|
||||||
RUN apk add --no-cache sqlite
|
|
||||||
|
|
||||||
# Copy built application from builder stage
|
# Copy built application from builder stage
|
||||||
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
|
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/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/node_modules ./node_modules
|
||||||
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
|
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
|
||||||
|
|
||||||
# Copy environment configuration
|
# Copy entrypoint script
|
||||||
COPY --chown=nodejs:nodejs .env.example .env
|
COPY docker-entrypoint.sh /usr/local/bin/
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
# Create data directory for SQLite
|
# Create the directory structure Strapi expects for the admin build
|
||||||
RUN mkdir -p /app/.tmp && \
|
RUN mkdir -p /app/node_modules/@strapi/admin/dist/server/server && \
|
||||||
chown -R nodejs:nodejs /app/.tmp
|
ln -sf /app/dist/build /app/node_modules/@strapi/admin/dist/server/server/build && \
|
||||||
|
chown -R nodejs:nodejs /app/node_modules/@strapi/admin
|
||||||
|
|
||||||
# Switch to non-root user
|
# Create data and database directories with proper permissions
|
||||||
USER nodejs
|
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
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
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) throw new Error()})"
|
CMD node -e "require('http').get('http://localhost:1337/_health', (r) => {if(r.statusCode !== 200) process.exit(1)})"
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 1337
|
EXPOSE 1337
|
||||||
|
|
||||||
|
# Use entrypoint to fix permissions on startup then switch to nodejs user
|
||||||
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
|
|
||||||
# Start Strapi
|
# Start Strapi
|
||||||
CMD ["npm", "run", "start"]
|
CMD ["npm", "run", "start"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ad31a55be0b5efc2d6d7b5490e8d1a31b1a3a2aecbcf99e8f65bcb44bd0189e2918388b942fb3ac7b35fd470d19d475e556489b773c68756977e452aec1ab83f34876ed76ebadafce31636d5621c66820425d1105d753cdc5452d8f3d503ddbaebf45fc6817c235e1f8eae12d118452951ee0a48691446475f7ffc6a72fd6ffb
|
||||||
@@ -17,4 +17,9 @@ export default ({ env }) => ({
|
|||||||
nps: env.bool('FLAG_NPS', true),
|
nps: env.bool('FLAG_NPS', true),
|
||||||
promoteEE: env.bool('FLAG_PROMOTE_EE', 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'),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
+38
-45
@@ -3,58 +3,51 @@ import path from 'path';
|
|||||||
export default ({ env }) => {
|
export default ({ env }) => {
|
||||||
const client = env('DATABASE_CLIENT', 'sqlite');
|
const client = env('DATABASE_CLIENT', 'sqlite');
|
||||||
|
|
||||||
const connections = {
|
console.log('=== DATABASE CONFIGURATION ===');
|
||||||
mysql: {
|
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: {
|
connection: {
|
||||||
host: env('DATABASE_HOST', 'localhost'),
|
client: 'postgres',
|
||||||
port: env.int('DATABASE_PORT', 3306),
|
connection: connectionConfig,
|
||||||
database: env('DATABASE_NAME', 'strapi'),
|
pool: {
|
||||||
user: env('DATABASE_USERNAME', 'strapi'),
|
min: env.int('DATABASE_POOL_MIN', 2),
|
||||||
password: env('DATABASE_PASSWORD', 'strapi'),
|
max: env.int('DATABASE_POOL_MAX', 10),
|
||||||
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),
|
|
||||||
},
|
},
|
||||||
|
acquireConnectionTimeout: env.int('DATABASE_TIMEOUT', 60000),
|
||||||
},
|
},
|
||||||
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
|
};
|
||||||
},
|
}
|
||||||
postgres: {
|
|
||||||
connection: {
|
console.log('Using SQLite configuration');
|
||||||
connectionString: env('DATABASE_URL'),
|
return {
|
||||||
host: env('DATABASE_HOST', 'localhost'),
|
connection: {
|
||||||
port: env.int('DATABASE_PORT', 5432),
|
client: 'sqlite',
|
||||||
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: {
|
|
||||||
connection: {
|
connection: {
|
||||||
filename: path.join(__dirname, '..', '..', env('DATABASE_FILENAME', '.tmp/data.db')),
|
filename: path.join(__dirname, '..', '..', env('DATABASE_FILENAME', '.tmp/data.db')),
|
||||||
},
|
},
|
||||||
useNullAsDefault: true,
|
useNullAsDefault: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
|
||||||
connection: {
|
|
||||||
client,
|
|
||||||
...connections[client],
|
|
||||||
acquireConnectionTimeout: env.int('DATABASE_CONNECTION_TIMEOUT', 60000),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
@@ -5,7 +5,17 @@ export default [
|
|||||||
'strapi::cors',
|
'strapi::cors',
|
||||||
'strapi::poweredBy',
|
'strapi::poweredBy',
|
||||||
'strapi::query',
|
'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::session',
|
||||||
'strapi::favicon',
|
'strapi::favicon',
|
||||||
'strapi::public',
|
'strapi::public',
|
||||||
|
|||||||
@@ -1 +1,7 @@
|
|||||||
export default () => ({});
|
export default ({ env }) => ({
|
||||||
|
upload: {
|
||||||
|
config: {
|
||||||
|
sizeLimit: 200 * 1024 * 1024, // 200MB in bytes
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export default ({ env }) => ({
|
export default ({ env }) => ({
|
||||||
host: env('HOST', '0.0.0.0'),
|
host: env('HOST', '0.0.0.0'),
|
||||||
port: env.int('PORT', 1337),
|
port: env.int('PORT', 1337),
|
||||||
|
url: env('PUBLIC_URL', 'https://cms.placebo.mk'),
|
||||||
app: {
|
app: {
|
||||||
keys: env.array('APP_KEYS'),
|
keys: env.array('APP_KEYS'),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 "$@"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
api token=5af45b836a0bcd065b528963e62b6d5d325117dfbf179cd5123087a17906c911fd67bcb57d92f11e869ebda27339b21aa3a7221d3dbbe79de51ef2f9e2af4dae0befe6528872ca2f68f64656ed45c7dfcacea36fdb55d1d9eb2fd9275454ac7ff8ab9acb1535f62449b3f8bd75c24803a2bd0714c637fd1d0bc819798723d999
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Script to create an API token for backend integration
|
||||||
|
* Run this inside the CMS container if the UI doesn't work
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { factories } from '@strapi/strapi';
|
||||||
|
|
||||||
|
async function createApiToken() {
|
||||||
|
const strapi = await factories.createCoreStore()();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create API token
|
||||||
|
const tokenService = strapi.service('admin::api-token');
|
||||||
|
|
||||||
|
const token = await tokenService.create({
|
||||||
|
name: 'Backend Integration',
|
||||||
|
description: 'Token for backend to fetch articles',
|
||||||
|
type: 'read-only',
|
||||||
|
permissions: [],
|
||||||
|
lifespan: null, // Unlimited
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ API Token created successfully!');
|
||||||
|
console.log('\nToken details:');
|
||||||
|
console.log('Name:', token.name);
|
||||||
|
console.log('Type:', token.type);
|
||||||
|
console.log('\nTOKEN (copy this to Coolify as STRAPI_API_TOKEN):');
|
||||||
|
console.log(token.accessKey);
|
||||||
|
console.log('\n⚠️ Save this token - you won\'t see it again!');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error creating API token:', error.message);
|
||||||
|
} finally {
|
||||||
|
await strapi.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createApiToken();
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"singularName": "article",
|
"singularName": "article",
|
||||||
"pluralName": "articles",
|
"pluralName": "articles",
|
||||||
"displayName": "article"
|
"displayName": "Article",
|
||||||
|
"description": "News articles for Placebo.mk"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"draftAndPublish": true
|
"draftAndPublish": true
|
||||||
@@ -12,33 +13,61 @@
|
|||||||
"pluginOptions": {},
|
"pluginOptions": {},
|
||||||
"attributes": {
|
"attributes": {
|
||||||
"title": {
|
"title": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"type": "uid",
|
||||||
|
"targetField": "title",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "text"
|
||||||
},
|
},
|
||||||
"content": {
|
"content": {
|
||||||
"type": "richtext"
|
"type": "richtext"
|
||||||
},
|
},
|
||||||
"media": {
|
|
||||||
"type": "media",
|
|
||||||
"multiple": true,
|
|
||||||
"allowedTypes": [
|
|
||||||
"images",
|
|
||||||
"files",
|
|
||||||
"videos",
|
|
||||||
"audios"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"author": {
|
"author": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"img": {
|
"img": {
|
||||||
"type": "media",
|
"type": "media",
|
||||||
"multiple": false,
|
"multiple": false,
|
||||||
"allowedTypes": [
|
"required": false,
|
||||||
"images",
|
"allowedTypes": ["images"]
|
||||||
"files",
|
},
|
||||||
"videos",
|
"imagePosition": {
|
||||||
"audios"
|
"type": "enumeration",
|
||||||
]
|
"enum": ["top", "left", "right", "none"],
|
||||||
|
"default": "top"
|
||||||
|
},
|
||||||
|
"imageSize": {
|
||||||
|
"type": "enumeration",
|
||||||
|
"enum": ["small", "medium", "large"],
|
||||||
|
"default": "medium"
|
||||||
|
},
|
||||||
|
"media": {
|
||||||
|
"type": "media",
|
||||||
|
"multiple": true,
|
||||||
|
"required": false,
|
||||||
|
"allowedTypes": ["images", "files", "videos", "audios"]
|
||||||
|
},
|
||||||
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* article controller
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { createCoreController } = require('@strapi/strapi').factories;
|
||||||
|
|
||||||
|
module.exports = createCoreController('api::article.article');
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
/**
|
|
||||||
* article controller
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { factories } from '@strapi/strapi';
|
|
||||||
|
|
||||||
export default factories.createCoreController('api::article.article');
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* article router
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { createCoreRouter } = require('@strapi/strapi').factories;
|
||||||
|
|
||||||
|
module.exports = createCoreRouter('api::article.article');
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
/**
|
|
||||||
* article router
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { factories } from '@strapi/strapi';
|
|
||||||
|
|
||||||
export default factories.createCoreRouter('api::article.article');
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* article service
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { createCoreService } = require('@strapi/strapi').factories;
|
||||||
|
|
||||||
|
module.exports = createCoreService('api::article.article');
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
/**
|
|
||||||
* article service
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { factories } from '@strapi/strapi';
|
|
||||||
|
|
||||||
export default factories.createCoreService('api::article.article');
|
|
||||||
@@ -16,5 +16,9 @@ export default {
|
|||||||
* This gives you an opportunity to set up your data model,
|
* This gives you an opportunity to set up your data model,
|
||||||
* run jobs, or perform some special logic.
|
* 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']);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
+12
@@ -446,6 +446,12 @@ export interface ApiArticleArticle extends Struct.CollectionTypeSchema {
|
|||||||
createdAt: Schema.Attribute.DateTime;
|
createdAt: Schema.Attribute.DateTime;
|
||||||
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||||
Schema.Attribute.Private;
|
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'>;
|
img: Schema.Attribute.Media<'images' | 'files' | 'videos' | 'audios'>;
|
||||||
locale: Schema.Attribute.String & Schema.Attribute.Private;
|
locale: Schema.Attribute.String & Schema.Attribute.Private;
|
||||||
localizations: Schema.Attribute.Relation<
|
localizations: Schema.Attribute.Relation<
|
||||||
@@ -462,6 +468,12 @@ export interface ApiArticleArticle extends Struct.CollectionTypeSchema {
|
|||||||
updatedAt: Schema.Attribute.DateTime;
|
updatedAt: Schema.Attribute.DateTime;
|
||||||
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||||
Schema.Attribute.Private;
|
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<''>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
# 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 with timeouts
|
||||||
|
- "traefik.http.services.cms.loadbalancer.server.port=1337"
|
||||||
|
- "traefik.http.services.cms.loadbalancer.responseForwarding.flushInterval=100ms"
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# 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
|
||||||
|
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
|
||||||
|
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
|
||||||
+9
-18
@@ -8,6 +8,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: placebo_user
|
POSTGRES_USER: placebo_user
|
||||||
POSTGRES_PASSWORD: placebo_password
|
POSTGRES_PASSWORD: placebo_password
|
||||||
|
POSTGRES_DB: placebo_backend_db
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data_dev:/var/lib/postgresql/data
|
- postgres_data_dev:/var/lib/postgresql/data
|
||||||
- ./scripts/init-postgres-dev.sql:/docker-entrypoint-initdb.d/init-postgres-dev.sql
|
- ./scripts/init-postgres-dev.sql:/docker-entrypoint-initdb.d/init-postgres-dev.sql
|
||||||
@@ -22,21 +23,11 @@ services:
|
|||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
container_name: placebo-backend-dev
|
container_name: placebo-backend-dev
|
||||||
|
env_file:
|
||||||
|
- ./backend/.env.docker # Use the .env.docker file for Docker configuration
|
||||||
environment:
|
environment:
|
||||||
|
# Only override if needed, most config is in .env.docker file
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
DATABASE_HOST: postgres
|
|
||||||
DATABASE_PORT: 5432
|
|
||||||
DATABASE_USERNAME: placebo_user
|
|
||||||
DATABASE_PASSWORD: placebo_password
|
|
||||||
DATABASE_NAME: placebo_backend_db
|
|
||||||
DATABASE_SYNCHRONIZE: "true"
|
|
||||||
DATABASE_LOGGING: "true"
|
|
||||||
JWT_SECRET: dev-jwt-secret
|
|
||||||
JWT_EXPIRATION: 3600
|
|
||||||
CORS_ORIGIN: http://localhost:5173
|
|
||||||
PORT: 3000
|
|
||||||
STRAPI_URL: http://cms:1337
|
|
||||||
STRAPI_API_TOKEN: 578d628f62df967ff95f95bedb205b5d10bbf792340519c8c467d6473208e16b3918151a97b49fa2285a53df0ec8e340a9ca555b01a654bd22152847840e6a368ee626a6f1338ce2f23790c171013b263ec80fbaf116e2b459d3663b234d08855fd0eb631991ed15bb94f7dbb0b80f190352965c72c7fd327c73629ceff38fbb
|
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -45,8 +36,8 @@ services:
|
|||||||
- ./backend/src:/app/src
|
- ./backend/src:/app/src
|
||||||
- ./backend/package.json:/app/package.json
|
- ./backend/package.json:/app/package.json
|
||||||
- ./backend/package-lock.json:/app/package-lock.json
|
- ./backend/package-lock.json:/app/package-lock.json
|
||||||
- ./backend/.env:/app/.env
|
- ./backend/.env.docker:/app/.env # Mount .env.docker file as .env in container
|
||||||
command: npm run start:dev
|
command: npm run dev:docker
|
||||||
networks:
|
networks:
|
||||||
- placebo-network-dev
|
- placebo-network-dev
|
||||||
|
|
||||||
@@ -56,10 +47,10 @@ services:
|
|||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
container_name: placebo-frontend-dev
|
container_name: placebo-frontend-dev
|
||||||
|
env_file:
|
||||||
|
- ./frontend/.env # Use the .env file for configuration
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
VITE_API_URL: http://localhost:3000/api/v1
|
|
||||||
VITE_CMS_URL: http://localhost:1337
|
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -75,7 +66,7 @@ services:
|
|||||||
- ./frontend/tsconfig.node.json:/app/tsconfig.node.json
|
- ./frontend/tsconfig.node.json:/app/tsconfig.node.json
|
||||||
- ./frontend/tailwind.config.js:/app/tailwind.config.js
|
- ./frontend/tailwind.config.js:/app/tailwind.config.js
|
||||||
- ./frontend/.env:/app/.env
|
- ./frontend/.env:/app/.env
|
||||||
command: npm run dev
|
command: npm run dev:docker
|
||||||
networks:
|
networks:
|
||||||
- placebo-network-dev
|
- placebo-network-dev
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+13
-15
@@ -6,7 +6,7 @@ services:
|
|||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: placebo-postgres
|
container_name: placebo-postgres
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: placebo_db
|
POSTGRES_DB: placebo_backend_db
|
||||||
POSTGRES_USER: placebo_user
|
POSTGRES_USER: placebo_user
|
||||||
POSTGRES_PASSWORD: placebo_password
|
POSTGRES_PASSWORD: placebo_password
|
||||||
volumes:
|
volumes:
|
||||||
@@ -15,7 +15,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U placebo_user -d placebo_db"]
|
test: ["CMD-SHELL", "pg_isready -U placebo_user -d placebo_backend_db"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -29,19 +29,17 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: placebo-backend
|
container_name: placebo-backend
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
DATABASE_TYPE: postgres
|
DATABASE_TYPE: postgres
|
||||||
DATABASE_HOST: postgres
|
DATABASE_HOST: postgres
|
||||||
DATABASE_PORT: 5432
|
DATABASE_PORT: 5432
|
||||||
DATABASE_USERNAME: placebo_user
|
DATABASE_USERNAME: placebo_user
|
||||||
DATABASE_PASSWORD: placebo_password
|
DATABASE_PASSWORD: placebo_password
|
||||||
DATABASE_NAME: placebo_db
|
DATABASE_NAME: placebo_backend_db
|
||||||
DATABASE_SYNCHRONIZE: "false"
|
DATABASE_SYNCHRONIZE: "false"
|
||||||
DATABASE_LOGGING: "false"
|
DATABASE_LOGGING: "false"
|
||||||
JWT_SECRET: ${JWT_SECRET:-your-jwt-secret-key-change-in-production}
|
CORS_ORIGIN: http://localhost:5173,http://localhost:3001
|
||||||
JWT_EXPIRATION: 3600
|
PORT: 3000
|
||||||
CORS_ORIGIN: http://localhost:5173,http://localhost:3001
|
|
||||||
PORT: 3000
|
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -1 +1,13 @@
|
|||||||
|
# Frontend Environment Configuration
|
||||||
|
# Copy to .env and adjust for your setup
|
||||||
|
|
||||||
|
# ===== DOCKER MODE (frontend in container) =====
|
||||||
|
# Use when frontend runs in Docker container
|
||||||
|
# VITE_API_URL=http://backend:3000/api/v1
|
||||||
|
|
||||||
|
# ===== LOCAL MODE (frontend runs locally) =====
|
||||||
|
# Use when frontend runs locally with npm run dev
|
||||||
VITE_API_URL=http://localhost:3000/api/v1
|
VITE_API_URL=http://localhost:3000/api/v1
|
||||||
|
|
||||||
|
# ===== COMMON =====
|
||||||
|
VITE_CMS_URL=http://localhost:1337
|
||||||
+11
-11
@@ -5,12 +5,19 @@ FROM node:20-alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Build arguments for environment variables
|
||||||
|
ARG VITE_API_URL
|
||||||
|
ARG VITE_CMS_URL
|
||||||
|
|
||||||
|
# Set environment variables for build
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
ENV VITE_CMS_URL=$VITE_CMS_URL
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package.json package-lock.json* ./
|
||||||
COPY package-lock.json* ./
|
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN npm ci
|
RUN npm install --legacy-peer-deps
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -21,19 +28,12 @@ RUN npm run build
|
|||||||
# Production stage
|
# Production stage
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|
||||||
# Create non-root user
|
|
||||||
RUN addgroup -g 1001 -S nginx && \
|
|
||||||
adduser -S nginx -u 1001 -G nginx
|
|
||||||
|
|
||||||
# Copy built application from builder stage
|
# Copy built application from builder stage
|
||||||
COPY --from=builder --chown=nginx:nginx /app/dist /usr/share/nginx/html
|
COPY --from=builder --chown=nginx:nginx /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
# Copy nginx configuration
|
# Copy nginx configuration
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
# Switch to non-root user
|
|
||||||
USER nginx
|
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
|
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
|
||||||
@@ -42,4 +42,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
# Start nginx
|
# Start nginx
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
+6
-3
@@ -1,10 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="mk">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=IBM+Plex+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<title>Placebo.mk - Сатирични вести од Македонија</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+29
-29
@@ -46,7 +46,7 @@ http {
|
|||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# Security headers for frontend
|
# Security headers for frontend
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' http://localhost:3000 http://localhost:1337;" always;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' http://localhost:3000 http://localhost:1337 https://api.placebo.mk https://cms.placebo.mk;" always;
|
||||||
|
|
||||||
# Handle React Router
|
# Handle React Router
|
||||||
location / {
|
location / {
|
||||||
@@ -60,35 +60,35 @@ http {
|
|||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|
||||||
# API proxy
|
# API proxy (for local dev only - in prod use public URL)
|
||||||
location /api/ {
|
# location /api/ {
|
||||||
proxy_pass http://backend:3000/;
|
# proxy_pass http://backend:3000/;
|
||||||
proxy_http_version 1.1;
|
# proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
# proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection 'upgrade';
|
# proxy_set_header Connection 'upgrade';
|
||||||
proxy_set_header Host $host;
|
# proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
# proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_cache_bypass $http_upgrade;
|
# proxy_cache_bypass $http_upgrade;
|
||||||
proxy_read_timeout 300;
|
# proxy_read_timeout 300;
|
||||||
proxy_connect_timeout 300;
|
# proxy_connect_timeout 300;
|
||||||
}
|
# }
|
||||||
|
|
||||||
# CMS proxy
|
# CMS proxy (for local dev only - in prod use public URL)
|
||||||
location /cms/ {
|
# location /cms/ {
|
||||||
proxy_pass http://cms:1337/;
|
# proxy_pass http://cms:1337/;
|
||||||
proxy_http_version 1.1;
|
# proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
# proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection 'upgrade';
|
# proxy_set_header Connection 'upgrade';
|
||||||
proxy_set_header Host $host;
|
# proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
# proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_cache_bypass $http_upgrade;
|
# proxy_cache_bypass $http_upgrade;
|
||||||
proxy_read_timeout 300;
|
# proxy_read_timeout 300;
|
||||||
proxy_connect_timeout 300;
|
# proxy_connect_timeout 300;
|
||||||
}
|
# }
|
||||||
|
|
||||||
# Health check endpoint
|
# Health check endpoint
|
||||||
location /health {
|
location /health {
|
||||||
|
|||||||
Generated
+1471
-22
File diff suppressed because it is too large.
Load diff
@@ -12,7 +12,10 @@
|
|||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:ui": "vitest --ui",
|
"test:ui": "vitest --ui",
|
||||||
"test:coverage": "vitest --coverage"
|
"test:coverage": "vitest --coverage",
|
||||||
|
"dev:docker": "vite",
|
||||||
|
"dev:local": "cp -f .env.local .env && vite",
|
||||||
|
"dev:reset-env": "cp -f .env.docker .env"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
@@ -25,7 +28,9 @@
|
|||||||
"@tanstack/react-router": "^1.144.0",
|
"@tanstack/react-router": "^1.144.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 609 KiB |
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import * as api from '@/lib/api'
|
import * as api from '@/lib/api'
|
||||||
|
import { Zap } from 'lucide-react'
|
||||||
|
|
||||||
export function ArticleTicker() {
|
export function ArticleTicker() {
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
@@ -13,37 +14,37 @@ export function ArticleTicker() {
|
|||||||
if (articles.length === 0) return null
|
if (articles.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden bg-muted/50 border-y">
|
<div className="overflow-hidden bg-foreground text-background border-b-4 border-accent">
|
||||||
<div className="container mx-auto max-w-6xl px-4">
|
<div className="py-2 flex items-center">
|
||||||
<div className="py-2 flex items-center gap-4">
|
<div className="flex-shrink-0 px-4 py-1 bg-accent text-foreground font-body text-sm font-bold uppercase tracking-wider flex items-center gap-2 border-r-4 border-background z-10">
|
||||||
<span className="text-sm font-semibold text-primary whitespace-nowrap">
|
<Zap className="w-4 h-4" />
|
||||||
Latest:
|
Топ вести
|
||||||
</span>
|
</div>
|
||||||
<div className="overflow-hidden flex-1 relative">
|
<div className="overflow-hidden flex-1">
|
||||||
<div className="flex animate-marquee whitespace-nowrap">
|
<div className="flex animate-marquee whitespace-nowrap">
|
||||||
{articles.map((article, index) => (
|
{articles.map((article, index) => (
|
||||||
<Link
|
<Link
|
||||||
key={`${article.id}-${index}`}
|
key={`${article.id}-${index}`}
|
||||||
to={`/articles/${article.id}` as any}
|
to="/articles/$id"
|
||||||
className="text-sm text-muted-foreground hover:text-foreground hover:underline inline-block px-4"
|
params={{ id: article.id }}
|
||||||
>
|
className="font-body text-sm uppercase tracking-wider text-background/80 hover:text-accent hover:bg-background/10 inline-block px-6 py-1 border-r border-background/20 transition-colors"
|
||||||
{article.title || 'No title'}
|
>
|
||||||
</Link>
|
{article.title || 'No title'}
|
||||||
))}
|
</Link>
|
||||||
{/* Duplicate for seamless scrolling */}
|
))}
|
||||||
{articles.map((article, index) => (
|
{articles.map((article, index) => (
|
||||||
<Link
|
<Link
|
||||||
key={`dup-${article.id}-${index}`}
|
key={`dup-${article.id}-${index}`}
|
||||||
to={`/articles/${article.id}` as any}
|
to="/articles/$id"
|
||||||
className="text-sm text-muted-foreground hover:text-foreground hover:underline inline-block px-4"
|
params={{ id: article.id }}
|
||||||
>
|
className="font-body text-sm uppercase tracking-wider text-background/80 hover:text-accent hover:bg-background/10 inline-block px-6 py-1 border-r border-background/20 transition-colors"
|
||||||
{article.title || 'No title'}
|
>
|
||||||
</Link>
|
{article.title || 'No title'}
|
||||||
))}
|
</Link>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { usePushStats, useSendPushNotification } from '@/queries/push';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Bell, Send, Users, AlertCircle, CheckCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
export function PushNotificationManager() {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [body, setBody] = useState('');
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
|
const [result, setResult] = useState<{ sent: number; failed: number } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: stats, isLoading: loadingStats } = usePushStats();
|
||||||
|
const sendMutation = useSendPushNotification();
|
||||||
|
|
||||||
|
const handleSendTest = async () => {
|
||||||
|
if (!title.trim() || !body.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sendMutation.mutateAsync({
|
||||||
|
title: title.trim(),
|
||||||
|
body: body.trim(),
|
||||||
|
url: url.trim() || undefined,
|
||||||
|
});
|
||||||
|
setResult(result);
|
||||||
|
setShowSuccess(true);
|
||||||
|
setTimeout(() => setShowSuccess(false), 5000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send notification:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValid = title.trim().length > 0 && body.trim().length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-brutal bg-card p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<Bell className="h-6 w-6 text-accent" />
|
||||||
|
<h2 className="text-2xl font-display uppercase">Push Notifications</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="border-brutal-sm bg-background p-5 h-full">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Users className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<span className="font-body text-sm uppercase tracking-wider text-muted-foreground">
|
||||||
|
Subscribers
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{loadingStats ? (
|
||||||
|
<div className="text-4xl font-display animate-pulse">...</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-5xl font-display">
|
||||||
|
{stats?.totalSubscribers ?? 0}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs font-body text-muted-foreground mt-2">
|
||||||
|
Active push subscribers
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div className="border-brutal-sm bg-background p-5">
|
||||||
|
<h3 className="font-body text-sm uppercase tracking-wider text-muted-foreground mb-4">
|
||||||
|
Send Notification
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block font-body text-xs uppercase tracking-wider text-muted-foreground mb-1">
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Enter notification title..."
|
||||||
|
className="w-full border-2 border-foreground bg-background px-3 py-2 font-body text-sm focus:border-accent focus:outline-none"
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block font-body text-xs uppercase tracking-wider text-muted-foreground mb-1">
|
||||||
|
Body
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
placeholder="Enter notification message..."
|
||||||
|
className="w-full border-2 border-foreground bg-background px-3 py-2 font-body text-sm focus:border-accent focus:outline-none resize-none"
|
||||||
|
rows={3}
|
||||||
|
maxLength={150}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block font-body text-xs uppercase tracking-wider text-muted-foreground mb-1">
|
||||||
|
URL (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="/article/slug or /live-blogs/slug"
|
||||||
|
className="w-full border-2 border-foreground bg-background px-3 py-2 font-body text-sm focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-4 pt-2">
|
||||||
|
<div className="text-xs font-body text-muted-foreground">
|
||||||
|
{title.length}/50 chars • {body.length}/150 chars
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="brutalAccent"
|
||||||
|
onClick={handleSendTest}
|
||||||
|
disabled={!isValid || sendMutation.isPending}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
{sendMutation.isPending ? 'Sending...' : 'Send to All'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSuccess && result && (
|
||||||
|
<div className="mt-4 p-3 border-2 border-green-500 bg-green-500/10 flex items-start gap-2">
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-body text-sm font-medium">
|
||||||
|
Notification sent!
|
||||||
|
</p>
|
||||||
|
<p className="font-body text-xs text-muted-foreground">
|
||||||
|
Delivered: {result.sent} • Failed: {result.failed}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sendMutation.isError && (
|
||||||
|
<div className="mt-4 p-3 border-2 border-red-500 bg-red-500/10 flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-5 w-5 text-red-500" />
|
||||||
|
<p className="font-body text-sm">
|
||||||
|
Failed to send notification. Please try again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 p-4 border-2 border-foreground/20 bg-foreground/5">
|
||||||
|
<p className="font-body text-xs text-muted-foreground">
|
||||||
|
<strong>Note:</strong> Notifications are sent to all subscribers with
|
||||||
|
push enabled. Use sparingly to avoid spam. Articles and live blog
|
||||||
|
updates are automatically pushed when published.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Input } from '../ui/input';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
|
||||||
|
import { Label } from '../ui/label';
|
||||||
|
|
||||||
|
interface LoginFormProps {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onSwitchToRegister?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginForm({ onSuccess, onSwitchToRegister }: LoginFormProps) {
|
||||||
|
const { login, isLoading } = useAuth();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
onSuccess?.();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Login failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md mx-auto">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Login</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter your credentials to access your account
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 text-sm bg-destructive/10 text-destructive rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Enter your username"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading ? 'Logging in...' : 'Login'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{onSwitchToRegister && (
|
||||||
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSwitchToRegister}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Register here
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Navigate } from '@tanstack/react-router';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
requiredRole?: 'admin' | 'contributor' | 'user';
|
||||||
|
redirectTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProtectedRoute({
|
||||||
|
children,
|
||||||
|
requiredRole,
|
||||||
|
redirectTo = '/'
|
||||||
|
}: ProtectedRouteProps) {
|
||||||
|
const { isAuthenticated, user, isLoading } = useAuth();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
||||||
|
<p className="mt-4 text-muted-foreground">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to={redirectTo} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredRole && user?.role !== requiredRole) {
|
||||||
|
return <Navigate to="/" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Input } from '../ui/input';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
|
||||||
|
import { Label } from '../ui/label';
|
||||||
|
|
||||||
|
interface RegisterFormProps {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onSwitchToLogin?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegisterForm({ onSuccess, onSwitchToLogin }: RegisterFormProps) {
|
||||||
|
const { register, isLoading } = useAuth();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError('Password must be at least 6 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await register(username, email, password);
|
||||||
|
onSuccess?.();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Registration failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md mx-auto">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Register</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Create a new account to start commenting and reacting
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 text-sm bg-destructive/10 text-destructive rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Choose a username"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="Enter your email"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Choose a password (min 6 characters)"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading ? 'Creating account...' : 'Register'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{onSwitchToLogin && (
|
||||||
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSwitchToLogin}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Login here
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useAuth } from '../../../contexts/AuthContext';
|
||||||
|
import { useCreateComment } from '../../../queries/comments';
|
||||||
|
import { Button } from '../../ui/button';
|
||||||
|
import { Textarea } from '../../ui/textarea';
|
||||||
|
import { Card, CardContent } from '../../ui/card';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { mk } from 'date-fns/locale';
|
||||||
|
import type { Comment } from '../../../lib/api';
|
||||||
|
|
||||||
|
interface CommentItemProps {
|
||||||
|
comment: Comment;
|
||||||
|
articleId?: string;
|
||||||
|
liveBlogId?: string;
|
||||||
|
depth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentItem({ comment, articleId, liveBlogId, depth = 0 }: CommentItemProps) {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const [showReplyForm, setShowReplyForm] = useState(false);
|
||||||
|
const [replyContent, setReplyContent] = useState('');
|
||||||
|
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
|
||||||
|
|
||||||
|
const createCommentMutation = useCreateComment();
|
||||||
|
|
||||||
|
const handleSubmitReply = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!replyContent.trim() || !isAuthenticated) return;
|
||||||
|
|
||||||
|
setIsSubmittingReply(true);
|
||||||
|
try {
|
||||||
|
await createCommentMutation.mutateAsync({
|
||||||
|
content: replyContent,
|
||||||
|
articleId,
|
||||||
|
liveBlogId,
|
||||||
|
parentCommentId: comment.id,
|
||||||
|
});
|
||||||
|
setReplyContent('');
|
||||||
|
setShowReplyForm(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to post reply:', error);
|
||||||
|
} finally {
|
||||||
|
setIsSubmittingReply(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Maximum depth to prevent infinite nesting (optional)
|
||||||
|
const maxDepth = 5;
|
||||||
|
const canReply = depth < maxDepth;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={depth > 0 ? 'ml-8 mt-4 border-l-2 border-border pl-4' : ''}>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{comment.user?.username || 'Анонимен корисник'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{format(new Date(comment.createdAt), 'dd MMMM yyyy, HH:mm', { locale: mk })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{comment.user?.role === 'admin' && (
|
||||||
|
<span className="px-2 py-1 text-xs rounded-full bg-primary/10 text-primary">
|
||||||
|
Администратор
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="whitespace-pre-wrap mb-4">{comment.content}</p>
|
||||||
|
|
||||||
|
{/* Reply button and form */}
|
||||||
|
{isAuthenticated && canReply && (
|
||||||
|
<div className="mt-4">
|
||||||
|
{!showReplyForm ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowReplyForm(true)}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
Одговори
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 p-4 border rounded-lg bg-muted/20">
|
||||||
|
<form onSubmit={handleSubmitReply}>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Вашиот одговор..."
|
||||||
|
value={replyContent}
|
||||||
|
onChange={(e) => setReplyContent(e.target.value)}
|
||||||
|
className="min-h-[80px] mb-3"
|
||||||
|
disabled={isSubmittingReply}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowReplyForm(false);
|
||||||
|
setReplyContent('');
|
||||||
|
}}
|
||||||
|
disabled={isSubmittingReply}
|
||||||
|
>
|
||||||
|
Откажи
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
disabled={!replyContent.trim() || isSubmittingReply}
|
||||||
|
>
|
||||||
|
{isSubmittingReply ? 'Поставување...' : 'Постави одговор'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reactions (if implemented) */}
|
||||||
|
{comment.reactions && (
|
||||||
|
<div className="flex items-center gap-4 mt-4 pt-4 border-t">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-sm text-muted-foreground">👍 {comment.reactions.likes}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-sm text-muted-foreground">👎 {comment.reactions.dislikes}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Render replies recursively */}
|
||||||
|
{comment.replies && comment.replies.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
{comment.replies.map((reply) => (
|
||||||
|
<CommentItem
|
||||||
|
key={reply.id}
|
||||||
|
comment={reply}
|
||||||
|
articleId={articleId}
|
||||||
|
liveBlogId={liveBlogId}
|
||||||
|
depth={depth + 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useAuth } from '../../../contexts/AuthContext';
|
||||||
|
import { useComments, useCreateComment } from '../../../queries/comments';
|
||||||
|
import { Button } from '../../ui/button';
|
||||||
|
import { Textarea } from '../../ui/textarea';
|
||||||
|
import { Card, CardContent } from '../../ui/card';
|
||||||
|
import { CommentItem } from './CommentItem';
|
||||||
|
|
||||||
|
interface CommentSectionProps {
|
||||||
|
articleId?: string;
|
||||||
|
liveBlogId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentSection({ articleId, liveBlogId }: CommentSectionProps) {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const [newComment, setNewComment] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const { data: commentsData, isLoading } = useComments({
|
||||||
|
articleId,
|
||||||
|
liveBlogId,
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createCommentMutation = useCreateComment();
|
||||||
|
|
||||||
|
const comments = commentsData?.data || [];
|
||||||
|
|
||||||
|
const handleSubmitComment = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!newComment.trim() || !isAuthenticated) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await createCommentMutation.mutateAsync({
|
||||||
|
content: newComment,
|
||||||
|
articleId,
|
||||||
|
liveBlogId,
|
||||||
|
});
|
||||||
|
setNewComment('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to post comment:', error);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-12 pt-8 border-t">
|
||||||
|
<h2 className="text-2xl font-bold mb-6">Коментари</h2>
|
||||||
|
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<Card className="mb-8">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<form onSubmit={handleSubmitComment}>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Што мислите за овој напис?"
|
||||||
|
value={newComment}
|
||||||
|
onChange={(e) => setNewComment(e.target.value)}
|
||||||
|
className="min-h-[100px] mb-4"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!newComment.trim() || isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Поставување...' : 'Постави коментар'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card className="mb-8">
|
||||||
|
<CardContent className="pt-6 text-center">
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
Најавете се за да можете да коментирате
|
||||||
|
</p>
|
||||||
|
<Button asChild>
|
||||||
|
<a href="/auth">Најави се</a>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">Вчитување коментари...</p>
|
||||||
|
</div>
|
||||||
|
) : comments.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6 text-center">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Сè уште нема коментари. Бидете првиот што ќе коментира!
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<CommentItem
|
||||||
|
key={comment.id}
|
||||||
|
comment={comment}
|
||||||
|
articleId={articleId}
|
||||||
|
liveBlogId={liveBlogId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loaded 100 of 260 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user