22 Commits

Author SHA1 Message Date
9e1792e4d0 Merge branch 'master' into feature/FA-11_CICD
Some checks failed
CI / build-backend (pull_request) Failing after 1m49s
CI / build-frontend (pull_request) Has been cancelled
2025-11-26 04:50:58 +00:00
gamer147
747a212fb0 Add assistant directly 2025-11-25 23:50:01 -05:00
gamer147
200bdaabed [FA-11] Try to add Claude assistant
Some checks failed
CI / build-frontend (pull_request) Has been cancelled
CI / build-backend (pull_request) Has been cancelled
2025-11-25 23:45:53 -05:00
gamer147
caa36648e2 Haven't checked yet 2025-11-25 23:29:55 -05:00
6f2454329d Merge pull request 'feature/FA-18_BootstrapFrontend' (#32) from feature/FA-18_BootstrapFrontend into master
Reviewed-on: #32
2025-11-24 18:37:29 +00:00
gamer147
fdf2ff7c1b [FA-18] Adjustments based on feeback 2025-11-24 13:36:49 -05:00
gamer147
e8596b67c4 [FA-18] Frontend bootstrapped 2025-11-24 13:25:29 -05:00
a01250696f Merge pull request 'feature/FA-5_ImageSupport' (#31) from feature/FA-5_ImageSupport into master
Reviewed-on: #31
2025-11-24 02:17:10 +00:00
gamer147
16ed16ff62 [FA-5] Adds image support with proper S3 upload and replacement after upload 2025-11-23 21:16:26 -05:00
gamer147
573a0f6e3f [FA-5] FileService setup, build scripts tweaked to be easier to maintain 2025-11-22 22:36:44 -05:00
gamer147
1adbb955cf [FA-5] Remove gateway.fgp 2025-11-22 22:30:46 -05:00
gamer147
71e27b5dbb [FA-5] Update gitignore for Fusion Gateway builds 2025-11-22 22:30:06 -05:00
708f1a5338 Merge pull request 'feature/FA-13_ApiGateway' (#30) from feature/FA-13_ApiGateway into master
Reviewed-on: #30
2025-11-22 23:14:22 +00:00
gamer147
df7978fb43 [FA-13] Works locally, probably works in CICD 2025-11-22 18:14:01 -05:00
gamer147
ceb4271182 [FA-13] Forgot gateway schema 2025-11-22 00:33:34 -05:00
gamer147
ffa51cfce4 [FA-13] Adds Fusion gateway, need to setup a local build and stitch process. Only NovelService included right now. 2025-11-22 00:33:06 -05:00
592fa7fb36 Merge pull request '[FA-10] Adds user service and authentication service' (#16) from feature/FA-10_UserService into master
Reviewed-on: #16
2025-11-22 04:09:32 +00:00
gamer147
6b8cf9961b [FA-10] Adds user service and authentication service 2025-11-21 23:08:29 -05:00
303a9e6a63 Merge pull request '[FA-9] Scheduler Service' (#15) from feature/FA-9_SchedulerService into master
Reviewed-on: #15
2025-11-20 18:54:24 +00:00
gamer147
3250cf1467 [FA-9] Postgres backing works 2025-11-20 13:53:58 -05:00
gamer147
0abb10bb00 [FA-9] Need to add persistence layer 2025-11-20 09:04:45 -05:00
e06b2137ba Merge pull request '[FA-4] Adds an event bus infrastructure, a RabbitMQ implementation and rewires existing mutations on NovelService to utilize it.' (#14) from feature/FA-4_EventBus into master
Reviewed-on: #14
2025-11-20 02:48:33 +00:00
165 changed files with 15814 additions and 176 deletions

View File

@@ -0,0 +1,122 @@
name: Build Gateway
on:
workflow_dispatch:
push:
branches:
- master
paths:
- 'FictionArchive.API/**'
env:
REGISTRY: ${{ gitea.server_url }}
IMAGE_NAME: ${{ gitea.repository_owner }}/fictionarchive-api
jobs:
build-gateway:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Install Fusion CLI
run: dotnet tool install -g HotChocolate.Fusion.CommandLine
- name: Create subgraphs directory
run: mkdir -p subgraphs
# Download all subgraph packages from latest successful builds
- name: Download Novel Service subgraph
uses: actions/download-artifact@v4
with:
name: novel-service-subgraph
path: subgraphs/novel
continue-on-error: true
- name: Download Translation Service subgraph
uses: actions/download-artifact@v4
with:
name: translation-service-subgraph
path: subgraphs/translation
continue-on-error: true
- name: Download Scheduler Service subgraph
uses: actions/download-artifact@v4
with:
name: scheduler-service-subgraph
path: subgraphs/scheduler
continue-on-error: true
- name: Download User Service subgraph
uses: actions/download-artifact@v4
with:
name: user-service-subgraph
path: subgraphs/user
continue-on-error: true
- name: Download File Service subgraph
uses: actions/download-artifact@v4
with:
name: file-service-subgraph
path: subgraphs/file
continue-on-error: true
- name: Configure subgraph URLs for Docker
run: |
for fsp in subgraphs/*/*.fsp; do
if [ -f "$fsp" ]; then
dir=$(dirname "$fsp")
name=$(basename "$dir")
url="http://${name}-service:8080/graphql"
echo "Setting $name URL to $url"
fusion subgraph config set http --url "$url" -c "$fsp"
fi
done
- name: Compose gateway
run: |
cd FictionArchive.API
rm -f gateway.fgp
for fsp in ../subgraphs/*/*.fsp; do
if [ -f "$fsp" ]; then
echo "Composing: $fsp"
fusion compose -p gateway.fgp -s "$fsp"
fi
done
- name: Restore dependencies
run: dotnet restore FictionArchive.API/FictionArchive.API.csproj
- name: Build gateway
run: dotnet build FictionArchive.API/FictionArchive.API.csproj -c Release --no-restore -p:SkipFusionBuild=true
- name: Run tests
run: dotnet test FictionArchive.sln -c Release --no-build --verbosity normal
continue-on-error: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: FictionArchive.API/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -0,0 +1,77 @@
name: Build Subgraphs
on:
push:
branches:
- master
paths:
- 'FictionArchive.Service.*/**'
- 'FictionArchive.Common/**'
- 'FictionArchive.Service.Shared/**'
jobs:
build-subgraphs:
runs-on: ubuntu-latest
strategy:
matrix:
service:
- name: novel-service
project: FictionArchive.Service.NovelService
subgraph: Novel
- name: translation-service
project: FictionArchive.Service.TranslationService
subgraph: Translation
- name: scheduler-service
project: FictionArchive.Service.SchedulerService
subgraph: Scheduler
- name: user-service
project: FictionArchive.Service.UserService
subgraph: User
- name: file-service
project: FictionArchive.Service.FileService
subgraph: File
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Install Fusion CLI
run: dotnet tool install -g HotChocolate.Fusion.CommandLine
- name: Restore dependencies
run: dotnet restore ${{ matrix.service.project }}/${{ matrix.service.project }}.csproj
- name: Build
run: dotnet build ${{ matrix.service.project }}/${{ matrix.service.project }}.csproj -c Release --no-restore
- name: Export schema
run: |
dotnet run --project ${{ matrix.service.project }}/${{ matrix.service.project }}.csproj \
--no-build -c Release --no-launch-profile \
-- schema export --output ${{ matrix.service.project }}/schema.graphql
- name: Pack subgraph
run: fusion subgraph pack -w ${{ matrix.service.project }}
- name: Upload subgraph package
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.service.name }}-subgraph
path: ${{ matrix.service.project }}/*.fsp
retention-days: 30
# Trigger gateway build after all subgraphs are built
trigger-gateway:
runs-on: ubuntu-latest
needs: build-subgraphs
steps:
- name: Trigger gateway workflow
run: |
curl -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/actions/workflows/build-gateway.yml/dispatches" \
-d '{"ref":"master"}'

View File

@@ -0,0 +1,63 @@
name: CI
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
build-backend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Fusion CLI
run: dotnet tool install -g HotChocolate.Fusion.CommandLine
- name: Restore dependencies
run: dotnet restore FictionArchive.sln
- name: Build solution
run: dotnet build FictionArchive.sln --configuration Release --no-restore
- name: Run tests
run: dotnet test FictionArchive.sln --configuration Release --no-build --verbosity normal
build-frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: fictionarchive-web
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: fictionarchive-web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Build
run: npm run build

View File

@@ -0,0 +1,43 @@
name: Claude Assistant for Gitea
on:
# Trigger on issue comments (works on both issues and pull requests in Gitea)
issue_comment:
types: [created]
# Trigger on issues being opened or assigned
issues:
types: [opened, assigned]
# Note: pull_request_review_comment has limited support in Gitea
# Use issue_comment instead which covers PR comments
jobs:
claude-assistant:
# Basic trigger detection - check for @claude in comments or issue body
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || github.event.action == 'assigned'))
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
# Note: Gitea Actions may not require id-token: write for basic functionality
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Claude Assistant
uses: markwylde/claude-code-gitea-action
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
gitea_token: ${{ secrets.CLAUDE_GITEA_TOKEN }}
timeout_minutes: "60"
trigger_phrase: "@claude"
# Optional: Customize for Gitea environment
custom_instructions: |
You are working in a Gitea environment. Be aware that:
- Some GitHub Actions features may behave differently
- Focus on core functionality and avoid advanced GitHub-specific features
- Use standard git operations when possible

View File

@@ -0,0 +1,98 @@
name: Release
on:
push:
tags:
- 'v*.*.*'
env:
REGISTRY: ${{ gitea.server_url }}
IMAGE_PREFIX: ${{ gitea.repository_owner }}/fictionarchive
jobs:
build-and-push:
runs-on: ubuntu-latest
strategy:
matrix:
service:
- name: api
dockerfile: FictionArchive.API/Dockerfile
- name: novel-service
dockerfile: FictionArchive.Service.NovelService/Dockerfile
- name: user-service
dockerfile: FictionArchive.Service.UserService/Dockerfile
- name: translation-service
dockerfile: FictionArchive.Service.TranslationService/Dockerfile
- name: file-service
dockerfile: FictionArchive.Service.FileService/Dockerfile
- name: scheduler-service
dockerfile: FictionArchive.Service.SchedulerService/Dockerfile
- name: authentication-service
dockerfile: FictionArchive.Service.AuthenticationService/Dockerfile
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.service.dockerfile }}
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-${{ matrix.service.name }}:${{ steps.version.outputs.VERSION }}
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-${{ matrix.service.name }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
build-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push frontend Docker image
uses: docker/build-push-action@v6
with:
context: ./fictionarchive-web
file: fictionarchive-web/Dockerfile
push: true
build-args: |
VITE_GRAPHQL_URI=${{ vars.VITE_GRAPHQL_URI }}
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
VITE_OIDC_CLIENT_ID=${{ vars.VITE_OIDC_CLIENT_ID }}
VITE_OIDC_REDIRECT_URI=${{ vars.VITE_OIDC_REDIRECT_URI }}
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=${{ vars.VITE_OIDC_POST_LOGOUT_REDIRECT_URI }}
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-frontend:${{ steps.version.outputs.VERSION }}
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-frontend:latest
cache-from: type=gha
cache-to: type=gha,mode=max

7
.gitignore vendored
View File

@@ -134,4 +134,9 @@ $RECYCLE.BIN/
_NCrunch*
# Local user appsettings
appsettings.Local.json
appsettings.Local.json
# Fusion Builds
schema.graphql
*.fsp
gateway.fgp

36
Documentation/AGENTS.md Normal file
View File

@@ -0,0 +1,36 @@
# Repository Guidelines
## Project Structure & Module Organization
- `FictionArchive.sln` ties together the gateway and all subgraph services.
- `FictionArchive.API`: Fusion gateway host; GraphQL endpoint at `/graphql`, health at `/healthz`, gateway configuration in `gateway.fgp`, and helper script `build_gateway.py`.
- `FictionArchive.Service.*`: GraphQL subgraphs (`AuthenticationService`, `FileService`, `NovelService`, `SchedulerService`, `TranslationService`, `UserService`) plus shared helpers in `FictionArchive.Service.Shared`.
- `FictionArchive.Common`: shared enums and hosting extensions used across services.
- Environment/config files live beside each service (`appsettings*.json`, `Properties/launchSettings.json`); build outputs under `bin/` and `obj/` should stay untracked.
## Build, Test, and Development Commands
- `dotnet restore` then `dotnet build FictionArchive.sln` (Debug by default) to validate all projects compile.
- Run the gateway: `dotnet run --project FictionArchive.API` (serves HTTPS; ensure certificates are trusted locally).
- Run a subgraph locally: `dotnet run --project FictionArchive.Service.NovelService` (or any other service) to debug a single domain.
- Rebuild the Fusion gateway config after subgraph changes: `python FictionArchive.API/build_gateway.py` (requires Python 3 and the `fusion` CLI on PATH; uses `gateway_skip.txt` to omit services).
- If tests are added, prefer `dotnet test FictionArchive.sln` to cover the whole solution.
## Coding Style & Naming Conventions
- Target .NET 8/C# 12; use 4-space indentation and file-scoped namespaces where practical.
- PascalCase for classes, records, interfaces, and public members; camelCase for locals/parameters; suffix async methods with `Async`.
- Favor dependency injection and extension methods for service wiring (see `Program.cs` files and `FictionArchive.Service.Shared/Extensions`).
- Keep GraphQL schema files and other generated artifacts out of commits unless intentionally versioned.
## Testing Guidelines
- No dedicated test projects exist yet; when adding tests, create `*.Tests` projects aligned to each service (e.g., `FictionArchive.Service.NovelService.Tests`) and name test files `*Tests.cs`.
- Prefer xUnit with fluent assertions; aim for coverage on controllers/resolvers, integration events, and critical extension methods.
- Use in-memory fakes or test containers for external dependencies to keep tests deterministic.
## Commit & Pull Request Guidelines
- Follow the observed pattern: `[FA-123] Short, imperative summary` (reference the tracker ID and keep scope focused).
- Keep commits small and self-contained; include relevant config/schema updates produced by the gateway build script when behavior changes.
- PRs should describe the problem, the solution, and any follow-up work; link to issues, attach GraphQL schema diffs or sample queries when applicable, and note any manual steps (migrations, secrets).
## Security & Configuration Tips
- Do not commit secrets; use user secrets or environment variables for API keys and connection strings referenced in `appsettings*.json`.
- Verify HTTPS is enabled locally; adjust `launchSettings.json` only when necessary and document non-default ports.
- Regenerate `gateway.fgp` after changing subgraph schemas to avoid stale compositions.

View File

@@ -0,0 +1,405 @@
# FictionArchive Architecture Overview
## High-Level Architecture
```
┌────────────────────────────────────────────────────────────────┐
│ React 19 Frontend │
│ (Apollo Client, TailwindCSS, OIDC Auth) │
└───────────────────────────┬────────────────────────────────────┘
│ GraphQL
┌────────────────────────────────────────────────────────────────┐
│ Hot Chocolate Fusion Gateway │
│ (FictionArchive.API) │
└──────┬────────┬────────┬────────┬────────┬─────────────────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌──────────┐┌──────────┐┌───────────┐┌──────────┐┌──────────────┐
│ Novel ││ User ││Translation││Scheduler ││ File │
│ Service ││ Service ││ Service ││ Service ││ Service │
└────┬─────┘└────┬─────┘└─────┬─────┘└────┬─────┘└──────┬───────┘
│ │ │ │ │
└───────────┴────────────┴───────────┴─────────────┘
┌────────┴────────┐
│ RabbitMQ │
│ (Event Bus) │
└─────────────────┘
┌────────┴────────┐
│ PostgreSQL │
│ (per service) │
└─────────────────┘
```
## Technology Stack
| Layer | Technology | Version |
|-------|------------|---------|
| Runtime | .NET | 8.0 |
| GraphQL | Hot Chocolate / Fusion | 13+ |
| Database | PostgreSQL | 12+ |
| ORM | Entity Framework Core | 8.0 |
| Message Broker | RabbitMQ | 3.12+ |
| Job Scheduler | Quartz.NET | Latest |
| Object Storage | AWS S3 / Garage | - |
| Date/Time | NodaTime | Latest |
| Frontend | React | 19.2 |
| Frontend Build | Vite | 7.2 |
| GraphQL Client | Apollo Client | 4.0 |
| Auth | OIDC Client TS | 3.4 |
| Styling | TailwindCSS | 3.4 |
| UI Components | Radix UI | Latest |
## Project Structure
```
FictionArchive.sln
├── FictionArchive.Common # Shared enums and extensions
├── FictionArchive.API # GraphQL Fusion Gateway
├── FictionArchive.Service.Shared # Shared infrastructure
├── FictionArchive.Service.NovelService
├── FictionArchive.Service.UserService
├── FictionArchive.Service.TranslationService
├── FictionArchive.Service.FileService
├── FictionArchive.Service.SchedulerService
├── FictionArchive.Service.AuthenticationService
├── FictionArchive.Service.NovelService.Tests
└── fictionarchive-web # React frontend
```
## Services
### FictionArchive.API - GraphQL Fusion Gateway
- **Role**: Single entry point for all GraphQL queries
- **Port**: 5001 (HTTPS)
- **Endpoints**:
- `/graphql` - GraphQL endpoint
- `/healthz` - Health check
- **Responsibilities**:
- Compose GraphQL schemas from all subgraphs
- Route queries to appropriate services
- CORS policy management
### FictionArchive.Service.NovelService
- **Role**: Novel/fiction content management
- **Port**: 8081 (HTTPS)
- **Database**: `FictionArchive_NovelService`
- **GraphQL Operations**:
- `GetNovels` - Paginated, filterable novel listing
- `ImportNovel` - Trigger novel import
- `FetchChapterContents` - Fetch chapter content
- **Models**: Novel, Chapter, Source, NovelTag, Image, LocalizationKey
- **External Integration**: Novelpia adapter
- **Events Published**: `TranslationRequestCreatedEvent`, `FileUploadRequestCreatedEvent`
- **Events Subscribed**: `TranslationRequestCompletedEvent`, `NovelUpdateRequestedEvent`, `ChapterPullRequestedEvent`, `FileUploadRequestStatusUpdateEvent`
### FictionArchive.Service.UserService
- **Role**: User identity and profile management
- **Port**: 8081 (HTTPS)
- **Database**: `FictionArchive_UserService`
- **Models**: User (with OAuth provider linking)
- **Events Subscribed**: `AuthUserAddedEvent`
### FictionArchive.Service.TranslationService
- **Role**: Text translation orchestration
- **Port**: 8081 (HTTPS)
- **Database**: `FictionArchive_TranslationService`
- **External Integration**: DeepL API
- **Models**: TranslationRequest
- **Events Published**: `TranslationRequestCompletedEvent`
- **Events Subscribed**: `TranslationRequestCreatedEvent`
### FictionArchive.Service.FileService
- **Role**: File storage and S3 proxy
- **Port**: 8080 (HTTP)
- **Protocol**: REST only (not GraphQL)
- **Endpoints**: `GET /api/{*path}` - S3 file proxy
- **External Integration**: S3-compatible storage (AWS S3 / Garage)
- **Events Published**: `FileUploadRequestStatusUpdateEvent`
- **Events Subscribed**: `FileUploadRequestCreatedEvent`
### FictionArchive.Service.SchedulerService
- **Role**: Job scheduling and automation
- **Port**: 8081 (HTTPS)
- **Database**: `FictionArchive_SchedulerService`
- **Scheduler**: Quartz.NET with persistent job store
- **GraphQL Operations**: `ScheduleEventJob`, `GetScheduledJobs`
- **Models**: SchedulerJob, EventJobTemplate
### FictionArchive.Service.AuthenticationService
- **Role**: OAuth/OIDC webhook receiver
- **Port**: 8080 (HTTP)
- **Protocol**: REST only
- **Endpoints**: `POST /api/AuthenticationWebhook/UserRegistered`
- **Events Published**: `AuthUserAddedEvent`
- **No Database** - Stateless webhook handler
## Communication Patterns
### GraphQL Federation
- Hot Chocolate Fusion Gateway composes subgraph schemas
- Schema export automated via `build_gateway.py`
- Each service defines its own Query/Mutation types
### Event-Driven Architecture (RabbitMQ)
- Direct exchange: `fiction-archive-event-bus`
- Per-service queues based on `ClientIdentifier`
- Routing key = event class name
- Headers: `X-Created-At`, `X-Event-Id`
- NodaTime JSON serialization
### Event Flow Examples
**Novel Import:**
```
1. Frontend → importNovel mutation
2. NovelService publishes NovelUpdateRequestedEvent
3. NovelUpdateRequestedEventHandler processes
4. Fetches metadata via NovelpiaAdapter
5. Publishes FileUploadRequestCreatedEvent (for cover)
6. FileService uploads to S3
7. FileService publishes FileUploadRequestStatusUpdateEvent
8. NovelService updates image path
```
**Translation:**
```
1. NovelService publishes TranslationRequestCreatedEvent
2. TranslationService translates via DeepL
3. TranslationService publishes TranslationRequestCompletedEvent
4. NovelService updates chapter translation
```
## Data Storage
### Database Pattern
- Database per service (PostgreSQL)
- Connection string format: `Host=localhost;Database=FictionArchive_{ServiceName};...`
- Auto-migration on startup via `dbContext.UpdateDatabase()`
### Audit Trail
- `AuditInterceptor` auto-sets `CreatedTime` and `LastUpdatedTime`
- `IAuditable` interface with NodaTime `Instant` fields
- `BaseEntity<TKey>` abstract base class
### Object Storage
- S3-compatible (AWS S3 or Garage)
- Path-style URLs for Garage compatibility
- Proxied through FileService
## Frontend Architecture
### Structure
```
fictionarchive-web/
├── src/
│ ├── auth/ # OIDC authentication
│ ├── components/ # React components
│ │ └── ui/ # Radix-based primitives
│ ├── pages/ # Route pages
│ ├── layouts/ # Layout components
│ ├── graphql/ # GraphQL queries
│ ├── __generated__/ # Codegen output
│ └── lib/ # Utilities
└── codegen.ts # GraphQL Codegen config
```
### Authentication
- OIDC via `oidc-client-ts`
- Environment variables for configuration
- `useAuth` hook for state access
### State Management
- Apollo Client for GraphQL state
- React Context for auth state
## Infrastructure
### Docker
- Multi-stage builds
- Base: `mcr.microsoft.com/dotnet/aspnet:8.0`
- Non-root user for security
- Ports: 8080 (HTTP) or 8081 (HTTPS)
### Health Checks
- All services expose `/healthz`
### Configuration
- `appsettings.json` - Default settings
- `appsettings.Development.json` - Dev overrides
- `appsettings.Local.json` - Local secrets (not committed)
---
# Improvement Recommendations
## Critical
### 1. Event Bus - No Dead Letter Queue or Retry Logic
**Location**: `FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQEventBus.cs:126-133`
**Issue**: Events are always ACK'd even on failure. No DLQ configuration for poison messages. Failed events are lost forever.
**Recommendation**: Implement retry with exponential backoff, dead-letter exchange, and poison message handling.
```csharp
// Example: Add retry and DLQ
catch (Exception e)
{
_logger.LogError(e, "Error handling event");
if (retryCount < maxRetries)
{
await channel.BasicNackAsync(@event.DeliveryTag, false, true); // requeue
}
else
{
// Send to DLQ
await channel.BasicNackAsync(@event.DeliveryTag, false, false);
}
}
```
### 2. CORS Configuration is Insecure
**Location**: `FictionArchive.API/Program.cs:24-33`
**Issue**: `AllowAnyOrigin()` allows requests from any domain, unsuitable for production.
**Recommendation**: Configure specific allowed origins via appsettings:
```csharp
builder.Services.AddCors(options =>
{
options.AddPolicy("Production", policy =>
{
policy.WithOrigins(builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>())
.AllowAnyMethod()
.AllowAnyHeader();
});
});
```
### 3. Auto-Migration on Startup
**Location**: `FictionArchive.Service.Shared/Services/Database/FictionArchiveDbContext.cs:23-38`
**Issue**: Running migrations at startup can cause race conditions with multiple instances and potential data corruption during rolling deployments.
**Recommendation**: Use a migration job, init container, or CLI tool instead of startup code.
## Important
### 4. No Circuit Breaker Pattern
**Issue**: External service calls (DeepL, Novelpia, S3) lack resilience patterns.
**Recommendation**: Add Polly for circuit breaker, retry, and timeout policies:
```csharp
builder.Services.AddHttpClient<ISourceAdapter, NovelpiaAdapter>()
.AddPolicyHandler(GetRetryPolicy())
.AddPolicyHandler(GetCircuitBreakerPolicy());
```
### 5. Missing Request Validation/Rate Limiting
**Issue**: No visible rate limiting on GraphQL mutations. `ImportNovel` could be abused.
**Recommendation**: Add rate limiting middleware and input validation.
### 6. Hardcoded Exchange Name
**Location**: `RabbitMQEventBus.cs:24`
**Issue**: `fiction-archive-event-bus` is hardcoded.
**Recommendation**: Move to configuration for environment flexibility.
### 7. No Distributed Tracing
**Issue**: Event correlation exists (`X-Event-Id` header) but not integrated with tracing.
**Recommendation**: Add OpenTelemetry for end-to-end request tracing across services.
### 8. Singleton AuditInterceptor
**Location**: `FictionArchiveDbContext.cs:20`
**Issue**: `new AuditInterceptor()` created per DbContext instance.
**Recommendation**: Register as singleton in DI and inject.
## Minor / Code Quality
### 9. Limited Test Coverage
**Issue**: Only `NovelService.Tests` exists. No integration tests for event handlers.
**Recommendation**: Add unit and integration tests for each service, especially event handlers.
### 10. Inconsistent Port Configuration
**Issue**: Some services use 8080 (HTTP), others 8081 (HTTPS).
**Recommendation**: Standardize on HTTPS with proper cert management.
### 11. No API Versioning
**Issue**: GraphQL schemas have no versioning strategy.
**Recommendation**: Consider schema versioning or deprecation annotations for breaking changes.
### 12. Frontend - No Error Boundary
**Issue**: React app lacks error boundaries for graceful failure handling.
**Recommendation**: Add React Error Boundaries around routes.
### 13. Missing Health Check Depth
**Issue**: Health checks only verify service is running, not dependencies.
**Recommendation**: Add database, RabbitMQ, and S3 health checks:
```csharp
builder.Services.AddHealthChecks()
.AddNpgSql(connectionString)
.AddRabbitMQ()
.AddS3(options => { });
```
### 14. Synchronous File Operations in Event Handlers
**Issue**: File uploads may block event handling thread for large files.
**Recommendation**: Consider async streaming for large files.
## Architectural Suggestions
### 15. Consider Outbox Pattern
**Issue**: Publishing events and saving to DB aren't transactional, could lead to inconsistent state.
**Recommendation**: Implement transactional outbox pattern for guaranteed delivery:
```
1. Save entity + outbox message in same transaction
2. Background worker publishes from outbox
3. Delete outbox message after successful publish
```
### 16. Gateway Schema Build Process
**Issue**: Python script (`build_gateway.py`) for schema composition requires manual execution.
**Recommendation**: Integrate into CI/CD pipeline or consider runtime schema polling.
### 17. Secret Management
**Issue**: Credentials in appsettings files.
**Recommendation**: Use Azure Key Vault, AWS Secrets Manager, HashiCorp Vault, or similar secret management solution.
---
## Key Files Reference
| File | Purpose |
|------|---------|
| `FictionArchive.API/Program.cs` | Gateway setup |
| `FictionArchive.API/build_gateway.py` | Schema composition script |
| `FictionArchive.Service.Shared/Services/EventBus/` | Event bus implementation |
| `FictionArchive.Service.Shared/Extensions/` | Service registration helpers |
| `FictionArchive.Service.Shared/Services/Database/` | DB infrastructure |
| `fictionarchive-web/src/auth/AuthContext.tsx` | Frontend auth state |

220
Documentation/CICD.md Normal file
View File

@@ -0,0 +1,220 @@
# CI/CD Configuration
This document describes the CI/CD pipeline configuration for FictionArchive using Gitea Actions.
## Workflows Overview
| Workflow | File | Trigger | Purpose |
|----------|------|---------|---------|
| CI | `build.yml` | Push/PR to master | Build and test all projects |
| Build Subgraphs | `build-subgraphs.yml` | Push to master (service changes) | Build GraphQL subgraph packages |
| Build Gateway | `build-gateway.yml` | Manual or triggered by subgraphs | Compose gateway and build Docker image |
| Release | `release.yml` | Tag `v*.*.*` | Build and push all Docker images |
## Pipeline Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ Push to master │
└─────────────────────────────┬───────────────────────────────────────┘
┌───────────────┴───────────────┐
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ build.yml │ │ build-subgraphs.yml │
│ (CI checks - always) │ │ (if service changes) │
└─────────────────────────┘ └────────────┬────────────┘
┌─────────────────────────┐
│ build-gateway.yml │
│ (compose & push API) │
└─────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Push tag v*.*.* │
└─────────────────────────────┬───────────────────────────────────────┘
┌─────────────────────────┐
│ release.yml │
│ (build & push all) │
└─────────────────────────┘
```
## Required Configuration
### Repository Secrets
Configure these in **Settings → Actions → Secrets**:
| Secret | Description | Required By |
|--------|-------------|-------------|
| `REGISTRY_TOKEN` | Gitea access token with `write:package` scope | `release.yml`, `build-gateway.yml` |
| `GITEA_TOKEN` | Gitea access token for API calls | `build-subgraphs.yml` |
#### Creating Access Tokens
1. Go to **Settings → Applications → Access Tokens**
2. Create a new token with the following scopes:
- `write:package` - Push container images
- `write:repository` - Trigger workflows via API
3. Copy the token and add it as a repository secret
### Repository Variables
Configure these in **Settings → Actions → Variables**:
| Variable | Description | Example | Required By |
|----------|-------------|---------|-------------|
| `VITE_GRAPHQL_URI` | GraphQL API endpoint URL | `https://api.fictionarchive.example.com/graphql/` | `release.yml` |
| `VITE_OIDC_AUTHORITY` | OIDC provider authority URL | `https://auth.example.com/application/o/fiction-archive/` | `release.yml` |
| `VITE_OIDC_CLIENT_ID` | OIDC client identifier | `your-client-id` | `release.yml` |
| `VITE_OIDC_REDIRECT_URI` | Post-login redirect URL | `https://fictionarchive.example.com/` | `release.yml` |
| `VITE_OIDC_POST_LOGOUT_REDIRECT_URI` | Post-logout redirect URL | `https://fictionarchive.example.com/` | `release.yml` |
## Workflow Details
### CI (`build.yml`)
**Trigger:** Push or pull request to `master`
**Jobs:**
1. `build-backend` - Builds .NET solution and runs tests
2. `build-frontend` - Builds React application with linting
**Requirements:**
- .NET 8.0 SDK
- Python 3.12
- Node.js 20
- HotChocolate Fusion CLI
### Build Subgraphs (`build-subgraphs.yml`)
**Trigger:** Push to `master` with changes in:
- `FictionArchive.Service.*/**`
- `FictionArchive.Common/**`
- `FictionArchive.Service.Shared/**`
**Jobs:**
1. `build-subgraphs` - Matrix job building each service's `.fsp` package
2. `trigger-gateway` - Triggers gateway rebuild via API
**Subgraphs Built:**
- Novel Service
- Translation Service
- Scheduler Service
- User Service
- File Service
**Artifacts:** Each subgraph produces a `.fsp` file retained for 30 days.
### Build Gateway (`build-gateway.yml`)
**Trigger:**
- Manual dispatch (`workflow_dispatch`)
- Push to `master` with changes in `FictionArchive.API/**`
- Triggered by `build-subgraphs.yml` completion
**Process:**
1. Downloads all subgraph `.fsp` artifacts
2. Configures Docker-internal URLs for each subgraph
3. Composes gateway schema using Fusion CLI
4. Builds and pushes API Docker image
**Image Tags:**
- `<registry>/<owner>/fictionarchive-api:latest`
- `<registry>/<owner>/fictionarchive-api:<commit-sha>`
### Release (`release.yml`)
**Trigger:** Push tag matching `v*.*.*` (e.g., `v1.0.0`)
**Jobs:**
1. `build-and-push` - Matrix job building all backend service images
2. `build-frontend` - Builds and pushes frontend image
**Services Built:**
- `fictionarchive-api`
- `fictionarchive-novel-service`
- `fictionarchive-user-service`
- `fictionarchive-translation-service`
- `fictionarchive-file-service`
- `fictionarchive-scheduler-service`
- `fictionarchive-authentication-service`
- `fictionarchive-frontend`
**Image Tags:**
- `<registry>/<owner>/fictionarchive-<service>:<version>`
- `<registry>/<owner>/fictionarchive-<service>:latest`
## Container Registry
Images are pushed to the Gitea Container Registry at:
```
<gitea-server-url>/<repository-owner>/fictionarchive-<service>:<tag>
```
### Pulling Images
```bash
# Login to registry
docker login <gitea-server-url> -u <username> -p <token>
# Pull an image
docker pull <gitea-server-url>/<owner>/fictionarchive-api:latest
```
## Creating a Release
1. Ensure all changes are committed and pushed to `master`
2. Create and push a version tag:
```bash
git tag v1.0.0
git push origin v1.0.0
```
3. The release workflow will automatically build and push all images
4. Monitor progress in **Actions** tab
## Troubleshooting
### Build Failures
**"REGISTRY_TOKEN secret not found"**
- Ensure the `REGISTRY_TOKEN` secret is configured in repository settings
- Verify the token has `write:package` scope
**"Failed to trigger gateway workflow"**
- Ensure `GITEA_TOKEN` secret is configured
- Verify the token has `write:repository` scope
**"No subgraph artifacts found"**
- The gateway build requires subgraph artifacts from a previous `build-subgraphs` run
- Trigger `build-subgraphs.yml` manually or push a change to a service
### Frontend Build Failures
**"VITE_* variables are empty"**
- Ensure all required variables are configured in repository settings
- Variables use `vars.*` context, not `secrets.*`
### Docker Push Failures
**"unauthorized: authentication required"**
- Verify `REGISTRY_TOKEN` has correct permissions
- Check that the token hasn't expired
## Local Testing
To test workflows locally before pushing:
```bash
# Install act (GitHub Actions local runner)
# Note: act has partial Gitea Actions compatibility
# Run CI workflow
act push -W .gitea/workflows/build.yml
# Run with specific event
act push --eventpath .gitea/test-event.json
```

187
Documentation/README.md Normal file
View File

@@ -0,0 +1,187 @@
# FictionArchive
A distributed microservices-based web application for managing fiction and novel content. Features include importing from external sources, multi-language translation, file storage, and user management.
## Architecture
FictionArchive uses a GraphQL Fusion gateway pattern to orchestrate multiple domain services with event-driven communication via RabbitMQ.
More information available in [ARCHITECTURE.md](ARCHITECTURE.md)
## Prerequisites
- .NET SDK 8.0+
- Node.js 20+
- Python 3 (for gateway build script)
- Docker & Docker Compose
- PostgreSQL 16+
- RabbitMQ 3+
**Required CLI Tools**
```bash
# Hot Chocolate Fusion CLI
dotnet tool install -g HotChocolate.Fusion.CommandLine
```
## Getting Started
### Local Development
1. **Clone the repository**
```bash
git clone <repository-url>
cd FictionArchive
```
2. **Start infrastructure** (PostgreSQL, RabbitMQ)
```bash
docker compose up -d postgres rabbitmq
```
3. **Build and run backend**
```bash
dotnet restore
dotnet build FictionArchive.sln
# Start services (in separate terminals or use a process manager)
dotnet run --project FictionArchive.Service.NovelService
dotnet run --project FictionArchive.Service.UserService
dotnet run --project FictionArchive.Service.TranslationService
dotnet run --project FictionArchive.Service.FileService
dotnet run --project FictionArchive.Service.SchedulerService
dotnet run --project FictionArchive.Service.AuthenticationService
# Start the gateway (builds fusion schema automatically)
dotnet run --project FictionArchive.API
```
4. **Build and run frontend**
```bash
cd fictionarchive-web
npm install
npm run codegen # Generate GraphQL types
npm run dev # Start dev server at http://localhost:5173
```
### Docker Deployment
1. **Create environment file**
```bash
cp .env.example .env
# Edit .env with your configuration
```
2. **Start all services**
```bash
docker compose up -d
```
## Configuration
### Environment Variables
Create a `.env` file in the project root:
```bash
# PostgreSQL
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your-secure-password
# RabbitMQ
RABBITMQ_USER=guest
RABBITMQ_PASSWORD=your-secure-password
# External Services
NOVELPIA_USERNAME=your-username
NOVELPIA_PASSWORD=your-password
DEEPL_API_KEY=your-api-key
# S3 Storage
S3_ENDPOINT=https://s3.example.com
S3_BUCKET=fictionarchive
S3_ACCESS_KEY=your-access-key
S3_SECRET_KEY=your-secret-key
# OIDC Authentication
OIDC_AUTHORITY=https://auth.example.com/application/o/fiction-archive/
OIDC_CLIENT_ID=your-client-id
```
### Frontend Environment
Create `fictionarchive-web/.env.local`:
```bash
VITE_GRAPHQL_URI=http://localhost:5234/graphql/
VITE_OIDC_AUTHORITY=https://auth.example.com/application/o/fiction-archive/
VITE_OIDC_CLIENT_ID=your-client-id
VITE_OIDC_REDIRECT_URI=http://localhost:5173/
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5173/
```
## Building the GraphQL Gateway
The API gateway uses Hot Chocolate Fusion to compose schemas from all subgraphs. The gateway schema is rebuilt automatically when building the API project.
**Manual rebuild:**
```bash
cd FictionArchive.API
python build_gateway.py
```
**Skip specific services** by adding them to `FictionArchive.API/gateway_skip.txt`:
```
FictionArchive.Service.NovelService.Tests
```
## CI/CD
The project uses Gitea Actions with the following workflows:
| Workflow | Trigger | Description |
|----------|---------|-------------|
| `build.yml` | Push/PR to master | CI checks - builds and tests |
| `build-subgraphs.yml` | Service changes on master | Builds subgraph `.fsp` packages |
| `build-gateway.yml` | Gateway changes or subgraph builds | Composes gateway and builds Docker image |
| `release.yml` | Tag `v*.*.*` | Builds and pushes all Docker images |
### Release Process
```bash
git tag v1.0.0
git push origin v1.0.0
```
## Project Structure
```
FictionArchive/
├── FictionArchive.sln
├── FictionArchive.Common/ # Shared enums and extensions
├── FictionArchive.Service.Shared/ # Shared infrastructure (EventBus, DB)
├── FictionArchive.API/ # GraphQL Fusion Gateway
├── FictionArchive.Service.NovelService/
├── FictionArchive.Service.UserService/
├── FictionArchive.Service.TranslationService/
├── FictionArchive.Service.FileService/
├── FictionArchive.Service.SchedulerService/
├── FictionArchive.Service.AuthenticationService/
├── FictionArchive.Service.NovelService.Tests/
├── fictionarchive-web/ # React frontend
├── docker-compose.yml
└── .gitea/workflows/ # CI/CD workflows
```
## Testing
```bash
# Run all tests
dotnet test FictionArchive.sln
# Run specific test project
dotnet test FictionArchive.Service.NovelService.Tests
```
## Documentation
- [ARCHITECTURE.md](ARCHITECTURE.md) - Detailed architecture documentation
- [AGENTS.md](AGENTS.md) - Development guidelines and coding standards

View File

@@ -1,75 +0,0 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.API.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class TestController : ControllerBase
{
/*private readonly FictionArchiveDbContext _dbContext;
private readonly ISourceAdapter _novelpiaAdapter;
private readonly ITranslationEngineAdapter _translationEngine;
public TestController(ISourceAdapter novelpiaAdapter, FictionArchiveDbContext dbContext, ITranslationEngineAdapter translationEngine)
{
_novelpiaAdapter = novelpiaAdapter;
_dbContext = dbContext;
_translationEngine = translationEngine;
}
[HttpGet("GetNovel")]
public async Task<Novel> GetNovel(string novelUrl)
{
var novel = await _novelpiaAdapter.GetMetadata(novelUrl);
novel.Source = new Source()
{
Name = "Novelpia",
Id = 1,
Url = "https://novelpia.com"
};
_dbContext.Novels.Add(novel);
await _dbContext.SaveChangesAsync();
return novel;
}
[HttpGet("GetChapter")]
public async Task<string> GetChapter(uint novelId, uint chapterNumber)
{
var novel = await _dbContext.Novels.Include(n => n.Chapters).ThenInclude(c => c.Translations).FirstOrDefaultAsync(n => n.Id == novelId);
var chapter = novel.Chapters.FirstOrDefault(c => c.Order == chapterNumber);
var rawChapter = await _novelpiaAdapter.GetRawChapter(chapter.Url);
chapter.Translations.Add(new ChapterTranslation()
{
Language = novel.RawLanguage,
Body = rawChapter
});
await _dbContext.SaveChangesAsync();
return rawChapter;
}
[HttpPost("TranslateChapter")]
public async Task<ChapterTranslation> TranslateChapter(uint novelId, uint chapterNumber, Language to)
{
var novel = await _dbContext.Novels.Include(n => n.Chapters)
.ThenInclude(c => c.Translations).FirstOrDefaultAsync(novel => novel.Id == novelId);
var chapter = novel.Chapters.FirstOrDefault(c => c.Order == chapterNumber);
var chapterRaw = chapter.Translations.FirstOrDefault(ct => ct.Language == novel.RawLanguage);
var newTranslation = new ChapterTranslation()
{
Language = to,
TranslationEngine = new TranslationEngine()
{
Name = "DeepL"
}
};
var translation = await _translationEngine.GetTranslation(chapterRaw.Body, novel.RawLanguage, to);
newTranslation.Body = translation;
chapter.Translations.Add(newTranslation);
await _dbContext.SaveChangesAsync();
return newTranslation;
}*/
}
}

View File

@@ -7,15 +7,23 @@ EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["FictionArchive.API/FictionArchive.API.csproj", "FictionArchive.API/"]
COPY ["FictionArchive.Common/FictionArchive.Common.csproj", "FictionArchive.Common/"]
COPY ["FictionArchive.Service.Shared/FictionArchive.Service.Shared.csproj", "FictionArchive.Service.Shared/"]
RUN dotnet restore "FictionArchive.API/FictionArchive.API.csproj"
COPY . .
COPY FictionArchive.API/ FictionArchive.API/
COPY FictionArchive.Common/ FictionArchive.Common/
COPY FictionArchive.Service.Shared/ FictionArchive.Service.Shared/
WORKDIR "/src/FictionArchive.API"
RUN dotnet build "./FictionArchive.API.csproj" -c $BUILD_CONFIGURATION -o /app/build
# Skip fusion build - gateway.fgp should be pre-composed in CI
RUN dotnet build "./FictionArchive.API.csproj" -c $BUILD_CONFIGURATION -o /app/build -p:SkipFusionBuild=true
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./FictionArchive.API.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
RUN dotnet publish "./FictionArchive.API.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false /p:SkipFusionBuild=true
FROM base AS final
WORKDIR /app

View File

@@ -11,6 +11,7 @@
<PackageReference Include="HotChocolate.AspNetCore" Version="15.1.11" />
<PackageReference Include="HotChocolate.Data" Version="15.1.11" />
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="15.1.11" />
<PackageReference Include="HotChocolate.Fusion" Version="15.1.11" />
<PackageReference Include="HotChocolate.Types.Scalars" Version="15.1.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<PrivateAssets>all</PrivateAssets>
@@ -20,6 +21,11 @@
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.7" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
</ItemGroup>
<!-- Builds the Fusion graph file before building the application itself (skipped in CI) -->
<Target Name="RunFusionBuild" BeforeTargets="BeforeBuild" Condition="'$(SkipFusionBuild)' != 'true'">
<Exec Command="python build_gateway.py $(FusionBuildArgs)" WorkingDirectory="$(ProjectDir)" />
</Target>
<ItemGroup>
<Content Include="..\.dockerignore">
@@ -28,8 +34,11 @@
</ItemGroup>
<ItemGroup>
<Folder Include="Controllers\" />
<Folder Include="GraphQL\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FictionArchive.Service.Shared\FictionArchive.Service.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,3 +1,5 @@
using FictionArchive.Service.Shared.Extensions;
namespace FictionArchive.API;
public class Program
@@ -6,39 +8,38 @@ public class Program
{
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// OpenAPI & REST
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddMemoryCache();
builder.Services.AddHealthChecks();
#region Fusion Gateway
builder.Services.AddHttpClient("Fusion");
builder.Services
.AddFusionGatewayServer()
.ConfigureFromFile("gateway.fgp")
.CoreBuilder.ApplySaneDefaults();
#endregion
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAllOrigins",
builder =>
{
builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapGraphQL();
app.UseCors("AllowAllOrigins");
app.MapHealthChecks("/healthz");
app.MapControllers();
app.MapGraphQL();
app.Run();
app.RunWithGraphQLCommands(args);
}
}

View File

@@ -23,7 +23,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"launchUrl": "graphql",
"applicationUrl": "https://localhost:7063;http://localhost:5234",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"

View File

@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""
Local development script for building the Fusion gateway.
This script is used for local development only. In CI/CD, subgraphs are built
separately and the gateway is composed from pre-built .fsp artifacts.
Usage:
python build_gateway.py
Requirements:
- .NET 8.0 SDK
- HotChocolate Fusion CLI (dotnet tool install -g HotChocolate.Fusion.CommandLine)
"""
import subprocess
import sys
import os
from pathlib import Path
def run(cmd, cwd=None):
"""Run a command and exit on failure."""
print(f"> {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=cwd)
if result.returncode != 0:
print(f"ERROR: command failed in {cwd or os.getcwd()}")
sys.exit(result.returncode)
def load_skip_list(skip_file: Path):
if not skip_file.exists():
print(f"WARNING: gateway_skip.txt not found at {skip_file}")
return set()
lines = skip_file.read_text().splitlines()
skip = {line.strip() for line in lines
if line.strip() and not line.strip().startswith("#")}
print("Skip list:", ", ".join(skip) if skip else "(none)")
return skip
# ----------------------------------------
# Setup paths
# ----------------------------------------
script_dir = Path(__file__).parent.resolve()
services_dir = (script_dir / "..").resolve()
api_dir = services_dir / "FictionArchive.API"
print(f"Script dir: {script_dir}")
print(f"Services dir: {services_dir}")
skip_file = script_dir / "gateway_skip.txt"
skip_list = load_skip_list(skip_file)
# ----------------------------------------
# Find services
# ----------------------------------------
print("\n----------------------------------------")
print(" Finding GraphQL services...")
print("----------------------------------------")
service_dirs = [
d for d in services_dir.glob("FictionArchive.Service.*")
if d.is_dir() and (d / "subgraph-config.json").exists()
]
selected_services = []
for d in service_dirs:
name = d.name
if name in skip_list:
print(f"Skipping: {name}")
else:
print(f"Found: {name}")
selected_services.append(d)
if not selected_services:
print("No services to process. Exiting.")
sys.exit(0)
# ----------------------------------------
# Export + pack
# ----------------------------------------
print("\n----------------------------------------")
print(" Exporting schemas & packing subgraphs...")
print("----------------------------------------")
for svc in selected_services:
name = svc.name
print(f"\nProcessing {name}")
# Build once
run(["dotnet", "build", "-c", "Release"], cwd=svc)
# Export schema
run([
"dotnet", "run",
"--no-build",
"--no-launch-profile",
"--",
"schema", "export",
"--output", "schema.graphql"
], cwd=svc)
# Pack subgraph
run(["fusion", "subgraph", "pack"], cwd=svc)
# ----------------------------------------
# Compose gateway
# ----------------------------------------
print("\n----------------------------------------")
print(" Running fusion compose...")
print("----------------------------------------")
if not api_dir.exists():
print(f"ERROR: FictionArchive.API not found at {api_dir}")
sys.exit(1)
gateway_file = api_dir / "gateway.fgp"
if gateway_file.exists():
gateway_file.unlink()
for svc in selected_services:
name = svc.name
print(f"Composing: {name}")
run([
"fusion", "compose",
"-p", "gateway.fgp",
"-s", f"..{os.sep}{name}"
], cwd=api_dir)
print("\n----------------------------------------")
print(" Fusion build complete!")
print("----------------------------------------")

View File

@@ -0,0 +1,5 @@
# List of service folders to skip
FictionArchive.Service.Shared
FictionArchive.Service.AuthenticationService
FictionArchive.Service.FileService
FictionArchive.Service.NovelService.Tests

View File

@@ -0,0 +1,8 @@
namespace FictionArchive.Common.Enums;
public enum RequestStatus
{
Failed = -1,
Pending = 0,
Success = 1
}

View File

@@ -0,0 +1,36 @@
using FictionArchive.Service.AuthenticationService.Models.Requests;
using FictionArchive.Service.AuthenticationService.Models.IntegrationEvents;
using FictionArchive.Service.Shared.Services.EventBus;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace FictionArchive.Service.AuthenticationService.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class AuthenticationWebhookController : ControllerBase
{
private readonly IEventBus _eventBus;
public AuthenticationWebhookController(IEventBus eventBus)
{
_eventBus = eventBus;
}
[HttpPost(nameof(UserRegistered))]
public async Task<ActionResult> UserRegistered([FromBody] UserRegisteredWebhookPayload payload)
{
var authUserAddedEvent = new AuthUserAddedEvent
{
OAuthProviderId = payload.OAuthProviderId,
InviterOAuthProviderId = payload.InviterOAuthProviderId,
EventUserEmail = payload.EventUserEmail,
EventUserUsername = payload.EventUserUsername
};
await _eventBus.Publish(authUserAddedEvent);
return Ok();
}
}
}

View File

@@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["FictionArchive.Service.AuthenticationService/FictionArchive.Service.AuthenticationService.csproj", "FictionArchive.Service.AuthenticationService/"]
RUN dotnet restore "FictionArchive.Service.AuthenticationService/FictionArchive.Service.AuthenticationService.csproj"
COPY . .
WORKDIR "/src/FictionArchive.Service.AuthenticationService"
RUN dotnet build "./FictionArchive.Service.AuthenticationService.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./FictionArchive.Service.AuthenticationService.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "FictionArchive.Service.AuthenticationService.dll"]

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.7" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
</ItemGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FictionArchive.Service.Shared\FictionArchive.Service.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Controllers\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@FictionArchive.Service.AuthenticationService_HostAddress = http://localhost:5091
GET {{FictionArchive.Service.AuthenticationService_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,16 @@
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.AuthenticationService.Models.IntegrationEvents;
public class AuthUserAddedEvent : IIntegrationEvent
{
public string OAuthProviderId { get; set; }
public string InviterOAuthProviderId { get; set; }
// The email of the user that created the event
public string EventUserEmail { get; set; }
// The username of the user that created the event
public string EventUserUsername { get; set; }
}

View File

@@ -0,0 +1,17 @@
namespace FictionArchive.Service.AuthenticationService.Models.Requests;
public class UserRegisteredWebhookPayload
{
// The body of the notification message
public string Body { get; set; }
public string OAuthProviderId { get; set; }
public string InviterOAuthProviderId { get; set; }
// The email of the user that created the event
public string EventUserEmail { get; set; }
// The username of the user that created the event
public string EventUserUsername { get; set; }
}

View File

@@ -0,0 +1,49 @@
using FictionArchive.Service.Shared;
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
namespace FictionArchive.Service.AuthenticationService;
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
#region Event Bus
builder.Services.AddRabbitMQ(opt =>
{
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
});
#endregion
builder.Services.AddHealthChecks();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.MapHealthChecks("/healthz");
app.UseAuthorization();
app.MapControllers();
app.Run();
}
}

View File

@@ -0,0 +1,41 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:23522",
"sslPort": 44397
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5091",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7223;http://localhost:5091",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"RabbitMQ": {
"ConnectionString": "amqp://localhost",
"ClientIdentifier": "AuthenticationService"
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,49 @@
using System.Web;
using Amazon.S3;
using Amazon.S3.Model;
using FictionArchive.Service.FileService.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace FictionArchive.Service.FileService.Controllers
{
[Route("api/{*path}")]
[ApiController]
public class S3ProxyController : ControllerBase
{
private readonly AmazonS3Client _amazonS3Client;
private readonly S3Configuration _s3Configuration;
public S3ProxyController(AmazonS3Client amazonS3Client, IOptions<S3Configuration> s3Configuration)
{
_amazonS3Client = amazonS3Client;
_s3Configuration = s3Configuration.Value;
}
[HttpGet]
public async Task<IActionResult> Get(string path)
{
var decodedPath = HttpUtility.UrlDecode(path);
try
{
var s3Response = await _amazonS3Client.GetObjectAsync(new GetObjectRequest()
{
BucketName = _s3Configuration.Bucket,
Key = decodedPath
});
return new FileStreamResult(s3Response.ResponseStream, s3Response.Headers.ContentType);
}
catch (AmazonS3Exception e)
{
if (e.Message == "Key not found")
{
return NotFound();
}
throw;
}
}
}
}

View File

@@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["FictionArchive.Service.ImageService/FictionArchive.Service.ImageService.csproj", "FictionArchive.Service.ImageService/"]
RUN dotnet restore "FictionArchive.Service.ImageService/FictionArchive.Service.ImageService.csproj"
COPY . .
WORKDIR "/src/FictionArchive.Service.ImageService"
RUN dotnet build "./FictionArchive.Service.ImageService.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./FictionArchive.Service.ImageService.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "FictionArchive.Service.ImageService.dll"]

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FictionArchive.Service.Shared\FictionArchive.Service.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="4.0.13.1" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<Folder Include="Controllers\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.FileService.Models.IntegrationEvents;
public class FileUploadRequestCreatedEvent : IIntegrationEvent
{
public Guid RequestId { get; set; }
public string FilePath { get; set; }
public byte[] FileData { get; set; }
}

View File

@@ -0,0 +1,22 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.FileService.Models.IntegrationEvents;
public class FileUploadRequestStatusUpdateEvent : IIntegrationEvent
{
public Guid RequestId { get; set; }
public RequestStatus Status { get; set; }
#region Success
public string? FileAccessUrl { get; set; }
#endregion
#region Failure
public string? ErrorMessage { get; set; }
#endregion
}

View File

@@ -0,0 +1,6 @@
namespace FictionArchive.Service.FileService.Models;
public class ProxyConfiguration
{
public string BaseUrl { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace FictionArchive.Service.FileService.Models;
public class S3Configuration
{
public string Url { get; set; }
public string Bucket { get; set; }
public string AccessKey { get; set; }
public string SecretKey { get; set; }
}

View File

@@ -0,0 +1,69 @@
using Amazon.Runtime;
using Amazon.S3;
using FictionArchive.Common.Extensions;
using FictionArchive.Service.FileService.Models;
using FictionArchive.Service.FileService.Models.IntegrationEvents;
using FictionArchive.Service.FileService.Services.EventHandlers;
using FictionArchive.Service.Shared.Extensions;
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
using Microsoft.Extensions.Options;
namespace FictionArchive.Service.FileService;
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.AddLocalAppsettings();
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddHealthChecks();
#region Event Bus
builder.Services.AddRabbitMQ(opt =>
{
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
})
.Subscribe<FileUploadRequestCreatedEvent, FileUploadRequestCreatedEventHandler>();
#endregion
builder.Services.Configure<ProxyConfiguration>(builder.Configuration.GetSection("ProxyConfiguration"));
// Add S3 Client
builder.Services.Configure<S3Configuration>(builder.Configuration.GetSection("S3"));
builder.Services.AddSingleton<AmazonS3Client>(provider =>
{
var config = provider.GetRequiredService<IOptions<S3Configuration>>().Value;
var s3Config = new AmazonS3Config
{
ServiceURL = config.Url, // Garage endpoint
ForcePathStyle = true, // REQUIRED for Garage
AuthenticationRegion = "garage"
};
return new AmazonS3Client(
new BasicAWSCredentials(config.AccessKey, config.SecretKey),
s3Config);
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.MapHealthChecks("/healthz");
app.MapControllers();
app.Run();
}
}

View File

@@ -0,0 +1,39 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:5546",
"sslPort": 44373
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5057",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7247;http://localhost:5057",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,58 @@
using Amazon.S3;
using Amazon.S3.Model;
using FictionArchive.Common.Enums;
using FictionArchive.Service.FileService.Models;
using FictionArchive.Service.FileService.Models.IntegrationEvents;
using FictionArchive.Service.Shared.Services.EventBus;
using Microsoft.Extensions.Options;
namespace FictionArchive.Service.FileService.Services.EventHandlers;
public class FileUploadRequestCreatedEventHandler : IIntegrationEventHandler<FileUploadRequestCreatedEvent>
{
private readonly ILogger<FileUploadRequestCreatedEventHandler> _logger;
private readonly AmazonS3Client _amazonS3Client;
private readonly IEventBus _eventBus;
private readonly S3Configuration _s3Configuration;
private readonly ProxyConfiguration _proxyConfiguration;
public FileUploadRequestCreatedEventHandler(ILogger<FileUploadRequestCreatedEventHandler> logger, AmazonS3Client amazonS3Client, IEventBus eventBus, IOptions<S3Configuration> s3Configuration, IOptions<ProxyConfiguration> proxyConfiguration)
{
_logger = logger;
_amazonS3Client = amazonS3Client;
_eventBus = eventBus;
_proxyConfiguration = proxyConfiguration.Value;
_s3Configuration = s3Configuration.Value;
}
public async Task Handle(FileUploadRequestCreatedEvent @event)
{
var putObjectRequest = new PutObjectRequest();
putObjectRequest.BucketName = _s3Configuration.Bucket;
putObjectRequest.Key = @event.FilePath;
putObjectRequest.UseChunkEncoding = false; // Needed to avoid an error with Garage
using MemoryStream memoryStream = new MemoryStream(@event.FileData);
putObjectRequest.InputStream = memoryStream;
var s3Response = await _amazonS3Client.PutObjectAsync(putObjectRequest);
if (s3Response.HttpStatusCode != System.Net.HttpStatusCode.OK)
{
_logger.LogError("An error occurred while uploading file to S3. Response code: {responsecode}", s3Response.HttpStatusCode);
await _eventBus.Publish(new FileUploadRequestStatusUpdateEvent()
{
RequestId = @event.RequestId,
Status = RequestStatus.Failed,
ErrorMessage = "An error occurred while uploading file to S3."
});
return;
}
await _eventBus.Publish(new FileUploadRequestStatusUpdateEvent()
{
Status = RequestStatus.Success,
RequestId = @event.RequestId,
FileAccessUrl = _proxyConfiguration.BaseUrl + "/" + @event.FilePath
});
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,22 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ProxyConfiguration": {
"BaseUrl": "https://localhost:7247/api"
},
"RabbitMQ": {
"ConnectionString": "amqp://localhost",
"ClientIdentifier": "FileService"
},
"S3": {
"Url": "https://s3.orfl.xyz",
"Bucket": "fictionarchive",
"AccessKey": "REPLACE_ME",
"SecretKey": "REPLACE_ME"
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.11" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\FictionArchive.Service.NovelService\\FictionArchive.Service.NovelService.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,165 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.FileService.IntegrationEvents;
using FictionArchive.Service.NovelService.Models.Configuration;
using FictionArchive.Service.NovelService.Models.Enums;
using FictionArchive.Service.NovelService.Models.Images;
using FictionArchive.Service.NovelService.Models.Localization;
using FictionArchive.Service.NovelService.Models.Novels;
using FictionArchive.Service.NovelService.Models.SourceAdapters;
using FictionArchive.Service.NovelService.Services;
using FictionArchive.Service.NovelService.Services.SourceAdapters;
using FictionArchive.Service.Shared.Services.EventBus;
using FluentAssertions;
using HtmlAgilityPack;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using Xunit;
namespace FictionArchive.Service.NovelService.Tests;
public class NovelUpdateServiceTests
{
private static NovelServiceDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<NovelServiceDbContext>()
.UseInMemoryDatabase($"NovelUpdateServiceTests-{Guid.NewGuid()}")
.Options;
return new NovelServiceDbContext(options, NullLogger<NovelServiceDbContext>.Instance);
}
private static NovelCreateResult CreateNovelWithSingleChapter(NovelServiceDbContext dbContext, Source source)
{
var chapter = new Chapter
{
Order = 1,
Revision = 1,
Url = "http://demo/chapter-1",
Name = LocalizationKey.CreateFromText("Chapter 1", Language.En),
Body = new LocalizationKey { Texts = new List<LocalizationText>() },
Images = new List<Image>()
};
var novel = new Novel
{
Url = "http://demo/novel",
ExternalId = "demo-1",
Author = new Person { Name = LocalizationKey.CreateFromText("Author", Language.En) },
RawLanguage = Language.En,
RawStatus = NovelStatus.InProgress,
Source = source,
Name = LocalizationKey.CreateFromText("Demo Novel", Language.En),
Description = LocalizationKey.CreateFromText("Description", Language.En),
Chapters = new List<Chapter> { chapter },
Tags = new List<NovelTag>()
};
dbContext.Novels.Add(novel);
dbContext.SaveChanges();
return new NovelCreateResult(novel, chapter);
}
private static NovelUpdateService CreateService(
NovelServiceDbContext dbContext,
ISourceAdapter adapter,
IEventBus eventBus,
string pendingImageUrl = "https://pending/placeholder.jpg")
{
var options = Options.Create(new NovelUpdateServiceConfiguration
{
PendingImageUrl = pendingImageUrl
});
return new NovelUpdateService(dbContext, NullLogger<NovelUpdateService>.Instance, new[] { adapter }, eventBus, options);
}
[Fact]
public async Task PullChapterContents_rewrites_images_and_publishes_requests()
{
using var dbContext = CreateDbContext();
var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" };
var (novel, chapter) = CreateNovelWithSingleChapter(dbContext, source);
var rawHtml = "<p>Hello</p><img src=\"http://img/x1.jpg\" alt=\"first\" /><img src=\"http://img/x2.jpg\" alt=\"second\" />";
var image1 = new ImageData { Url = "http://img/x1.jpg", Data = new byte[] { 1, 2, 3 } };
var image2 = new ImageData { Url = "http://img/x2.jpg", Data = new byte[] { 4, 5, 6 } };
var adapter = Substitute.For<ISourceAdapter>();
adapter.SourceDescriptor.Returns(new SourceDescriptor { Key = "demo", Name = "Demo", Url = "http://demo" });
adapter.GetRawChapter(chapter.Url).Returns(Task.FromResult(new ChapterFetchResult
{
Text = rawHtml,
ImageData = new List<ImageData> { image1, image2 }
}));
var publishedEvents = new List<FileUploadRequestCreatedEvent>();
var eventBus = Substitute.For<IEventBus>();
eventBus.Publish(Arg.Do<FileUploadRequestCreatedEvent>(publishedEvents.Add)).Returns(Task.CompletedTask);
eventBus.Publish(Arg.Any<object>(), Arg.Any<string>()).Returns(Task.CompletedTask);
var pendingImageUrl = "https://pending/placeholder.jpg";
var service = CreateService(dbContext, adapter, eventBus, pendingImageUrl);
var updatedChapter = await service.PullChapterContents(novel.Id, chapter.Order);
updatedChapter.Images.Should().HaveCount(2);
updatedChapter.Images.Select(i => i.OriginalPath).Should().BeEquivalentTo(new[] { image1.Url, image2.Url });
updatedChapter.Images.All(i => i.Id != Guid.Empty).Should().BeTrue();
var storedHtml = updatedChapter.Body.Texts.Single().Text;
var doc = new HtmlDocument();
doc.LoadHtml(storedHtml);
var imgNodes = doc.DocumentNode.SelectNodes("//img");
imgNodes.Should().NotBeNull();
imgNodes!.Count.Should().Be(2);
imgNodes.Should().OnlyContain(node => node.GetAttributeValue("src", string.Empty) == pendingImageUrl);
imgNodes.Select(node => node.GetAttributeValue("alt", string.Empty))
.Should()
.BeEquivalentTo(updatedChapter.Images.Select(img => img.Id.ToString()));
publishedEvents.Should().HaveCount(2);
publishedEvents.Select(e => e.RequestId).Should().BeEquivalentTo(updatedChapter.Images.Select(i => i.Id));
publishedEvents.Select(e => e.FileData).Should().BeEquivalentTo(new[] { image1.Data, image2.Data });
publishedEvents.Should().OnlyContain(e => e.FilePath.StartsWith($"{novel.Id}/Images/Chapter-{updatedChapter.Id}/"));
}
[Fact]
public async Task PullChapterContents_adds_alt_when_missing()
{
using var dbContext = CreateDbContext();
var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" };
var (novel, chapter) = CreateNovelWithSingleChapter(dbContext, source);
var rawHtml = "<p>Hi</p><img src=\"http://img/x1.jpg\">";
var image = new ImageData { Url = "http://img/x1.jpg", Data = new byte[] { 7, 8, 9 } };
var adapter = Substitute.For<ISourceAdapter>();
adapter.SourceDescriptor.Returns(new SourceDescriptor { Key = "demo", Name = "Demo", Url = "http://demo" });
adapter.GetRawChapter(chapter.Url).Returns(Task.FromResult(new ChapterFetchResult
{
Text = rawHtml,
ImageData = new List<ImageData> { image }
}));
var eventBus = Substitute.For<IEventBus>();
eventBus.Publish(Arg.Any<FileUploadRequestCreatedEvent>()).Returns(Task.CompletedTask);
eventBus.Publish(Arg.Any<object>(), Arg.Any<string>()).Returns(Task.CompletedTask);
var service = CreateService(dbContext, adapter, eventBus);
var updatedChapter = await service.PullChapterContents(novel.Id, chapter.Order);
var storedHtml = updatedChapter.Body.Texts.Single().Text;
var doc = new HtmlDocument();
doc.LoadHtml(storedHtml);
var imgNode = doc.DocumentNode.SelectSingleNode("//img");
imgNode.Should().NotBeNull();
imgNode!.GetAttributeValue("alt", string.Empty).Should().Be(updatedChapter.Images.Single().Id.ToString());
imgNode.GetAttributeValue("src", string.Empty).Should().Be("https://pending/placeholder.jpg");
}
private record NovelCreateResult(Novel Novel, Chapter Chapter);
}

View File

@@ -8,6 +8,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="HotChocolate.AspNetCore.CommandLine" Version="15.1.11" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@@ -0,0 +1,540 @@
// <auto-generated />
using System;
using FictionArchive.Service.NovelService.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FictionArchive.Service.NovelService.Migrations
{
[DbContext(typeof(NovelServiceDbContext))]
[Migration("20251123203953_AddImages")]
partial class AddImages
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Images.Image", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<long?>("ChapterId")
.HasColumnType("bigint");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("NewPath")
.HasColumnType("text");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ChapterId");
b.ToTable("Images");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("LocalizationKeys");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("EngineId")
.HasColumnType("bigint");
b.Property<Guid>("KeyRequestedForTranslationId")
.HasColumnType("uuid");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("TranslateTo")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("EngineId");
b.HasIndex("KeyRequestedForTranslationId");
b.ToTable("LocalizationRequests");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationText", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("Language")
.HasColumnType("integer");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("LocalizationKeyId")
.HasColumnType("uuid");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("text");
b.Property<long?>("TranslationEngineId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("LocalizationKeyId");
b.HasIndex("TranslationEngineId");
b.ToTable("LocalizationText");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Chapter", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Guid>("BodyId")
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.Property<long?>("NovelId")
.HasColumnType("bigint");
b.Property<long>("Order")
.HasColumnType("bigint");
b.Property<long>("Revision")
.HasColumnType("bigint");
b.Property<string>("Url")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("BodyId");
b.HasIndex("NameId");
b.HasIndex("NovelId");
b.ToTable("Chapter");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("AuthorId")
.HasColumnType("bigint");
b.Property<Guid?>("CoverImageId")
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("DescriptionId")
.HasColumnType("uuid");
b.Property<string>("ExternalId")
.IsRequired()
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.Property<int>("RawLanguage")
.HasColumnType("integer");
b.Property<int>("RawStatus")
.HasColumnType("integer");
b.Property<long>("SourceId")
.HasColumnType("bigint");
b.Property<int?>("StatusOverride")
.HasColumnType("integer");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AuthorId");
b.HasIndex("CoverImageId");
b.HasIndex("DescriptionId");
b.HasIndex("NameId");
b.HasIndex("SourceId");
b.ToTable("Novels");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.NovelTag", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("DisplayNameId")
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long?>("SourceId")
.HasColumnType("bigint");
b.Property<int>("TagType")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("DisplayNameId");
b.HasIndex("SourceId");
b.ToTable("Tags");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Person", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExternalUrl")
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("NameId");
b.ToTable("Person");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Source", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Sources");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("TranslationEngines");
});
modelBuilder.Entity("NovelNovelTag", b =>
{
b.Property<long>("NovelsId")
.HasColumnType("bigint");
b.Property<long>("TagsId")
.HasColumnType("bigint");
b.HasKey("NovelsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("NovelNovelTag");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Images.Image", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Chapter", "Chapter")
.WithMany("Images")
.HasForeignKey("ChapterId");
b.Navigation("Chapter");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationRequest", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", "Engine")
.WithMany()
.HasForeignKey("EngineId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "KeyRequestedForTranslation")
.WithMany()
.HasForeignKey("KeyRequestedForTranslationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Engine");
b.Navigation("KeyRequestedForTranslation");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationText", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", null)
.WithMany("Texts")
.HasForeignKey("LocalizationKeyId");
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", "TranslationEngine")
.WithMany()
.HasForeignKey("TranslationEngineId");
b.Navigation("TranslationEngine");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Chapter", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Body")
.WithMany()
.HasForeignKey("BodyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name")
.WithMany()
.HasForeignKey("NameId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", null)
.WithMany("Chapters")
.HasForeignKey("NovelId");
b.Navigation("Body");
b.Navigation("Name");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Person", "Author")
.WithMany()
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Images.Image", "CoverImage")
.WithMany()
.HasForeignKey("CoverImageId");
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Description")
.WithMany()
.HasForeignKey("DescriptionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name")
.WithMany()
.HasForeignKey("NameId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Source", "Source")
.WithMany()
.HasForeignKey("SourceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Author");
b.Navigation("CoverImage");
b.Navigation("Description");
b.Navigation("Name");
b.Navigation("Source");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.NovelTag", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "DisplayName")
.WithMany()
.HasForeignKey("DisplayNameId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Source", "Source")
.WithMany()
.HasForeignKey("SourceId");
b.Navigation("DisplayName");
b.Navigation("Source");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Person", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name")
.WithMany()
.HasForeignKey("NameId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Name");
});
modelBuilder.Entity("NovelNovelTag", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", null)
.WithMany()
.HasForeignKey("NovelsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.NovelTag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", b =>
{
b.Navigation("Texts");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Chapter", b =>
{
b.Navigation("Images");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b =>
{
b.Navigation("Chapters");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,79 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace FictionArchive.Service.NovelService.Migrations
{
/// <inheritdoc />
public partial class AddImages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "CoverImageId",
table: "Novels",
type: "uuid",
nullable: true);
migrationBuilder.CreateTable(
name: "Images",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
OriginalPath = table.Column<string>(type: "text", nullable: false),
NewPath = table.Column<string>(type: "text", nullable: true),
ChapterId = table.Column<long>(type: "bigint", nullable: true),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Images", x => x.Id);
table.ForeignKey(
name: "FK_Images_Chapter_ChapterId",
column: x => x.ChapterId,
principalTable: "Chapter",
principalColumn: "Id");
});
migrationBuilder.CreateIndex(
name: "IX_Novels_CoverImageId",
table: "Novels",
column: "CoverImageId");
migrationBuilder.CreateIndex(
name: "IX_Images_ChapterId",
table: "Images",
column: "ChapterId");
migrationBuilder.AddForeignKey(
name: "FK_Novels_Images_CoverImageId",
table: "Novels",
column: "CoverImageId",
principalTable: "Images",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Novels_Images_CoverImageId",
table: "Novels");
migrationBuilder.DropTable(
name: "Images");
migrationBuilder.DropIndex(
name: "IX_Novels_CoverImageId",
table: "Novels");
migrationBuilder.DropColumn(
name: "CoverImageId",
table: "Novels");
}
}
}

View File

@@ -23,6 +23,35 @@ namespace FictionArchive.Service.NovelService.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Images.Image", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<long?>("ChapterId")
.HasColumnType("bigint");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("NewPath")
.HasColumnType("text");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ChapterId");
b.ToTable("Images");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", b =>
{
b.Property<Guid>("Id")
@@ -158,6 +187,9 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<long>("AuthorId")
.HasColumnType("bigint");
b.Property<Guid?>("CoverImageId")
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
@@ -194,6 +226,8 @@ namespace FictionArchive.Service.NovelService.Migrations
b.HasIndex("AuthorId");
b.HasIndex("CoverImageId");
b.HasIndex("DescriptionId");
b.HasIndex("NameId");
@@ -335,6 +369,15 @@ namespace FictionArchive.Service.NovelService.Migrations
b.ToTable("NovelNovelTag");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Images.Image", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Chapter", "Chapter")
.WithMany("Images")
.HasForeignKey("ChapterId");
b.Navigation("Chapter");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationRequest", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", "Engine")
@@ -398,6 +441,10 @@ namespace FictionArchive.Service.NovelService.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Images.Image", "CoverImage")
.WithMany()
.HasForeignKey("CoverImageId");
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Description")
.WithMany()
.HasForeignKey("DescriptionId")
@@ -418,6 +465,8 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Navigation("Author");
b.Navigation("CoverImage");
b.Navigation("Description");
b.Navigation("Name");
@@ -473,6 +522,11 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Navigation("Texts");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Chapter", b =>
{
b.Navigation("Images");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b =>
{
b.Navigation("Chapters");

View File

@@ -0,0 +1,6 @@
namespace FictionArchive.Service.NovelService.Models.Configuration;
public class NovelUpdateServiceConfiguration
{
public string PendingImageUrl { get; set; }
}

View File

@@ -0,0 +1,13 @@
using FictionArchive.Service.NovelService.Models.Novels;
using FictionArchive.Service.Shared.Models;
namespace FictionArchive.Service.NovelService.Models.Images;
public class Image : BaseEntity<Guid>
{
public string OriginalPath { get; set; }
public string? NewPath { get; set; }
// Chapter link. Even if an image appears in another chapter, we should rehost it separately.
public Chapter? Chapter { get; set; }
}

View File

@@ -2,7 +2,7 @@ using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Models.IntegrationEvents;
public class ChapterPullRequestedEvent : IntegrationEvent
public class ChapterPullRequestedEvent : IIntegrationEvent
{
public uint NovelId { get; set; }
public uint ChapterNumber { get; set; }

View File

@@ -0,0 +1,10 @@
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.FileService.IntegrationEvents;
public class FileUploadRequestCreatedEvent : IIntegrationEvent
{
public Guid RequestId { get; set; }
public string FilePath { get; set; }
public byte[] FileData { get; set; }
}

View File

@@ -0,0 +1,22 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Models.IntegrationEvents;
public class FileUploadRequestStatusUpdateEvent : IIntegrationEvent
{
public Guid RequestId { get; set; }
public RequestStatus Status { get; set; }
#region Success
public string? FileAccessUrl { get; set; }
#endregion
#region Failure
public string? ErrorMessage { get; set; }
#endregion
}

View File

@@ -2,7 +2,7 @@ using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Models.IntegrationEvents;
public class NovelUpdateRequestedEvent : IntegrationEvent
public class NovelUpdateRequestedEvent : IIntegrationEvent
{
public string NovelUrl { get; set; }
}

View File

@@ -3,7 +3,7 @@ using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Models.IntegrationEvents;
public class TranslationRequestCompletedEvent : IntegrationEvent
public class TranslationRequestCompletedEvent : IIntegrationEvent
{
/// <summary>
/// Maps this event back to a triggering request.

View File

@@ -3,7 +3,7 @@ using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Models.IntegrationEvents;
public class TranslationRequestCreatedEvent : IntegrationEvent
public class TranslationRequestCreatedEvent : IIntegrationEvent
{
public Guid TranslationRequestId { get; set; }
public Language From { get; set; }

View File

@@ -1,3 +1,4 @@
using FictionArchive.Service.NovelService.Models.Images;
using FictionArchive.Service.NovelService.Models.Localization;
using FictionArchive.Service.Shared.Models;
@@ -11,4 +12,7 @@ public class Chapter : BaseEntity<uint>
public LocalizationKey Name { get; set; }
public LocalizationKey Body { get; set; }
// Images appearing in this chapter.
public List<Image> Images { get; set; }
}

View File

@@ -1,4 +1,5 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.NovelService.Models.Images;
using FictionArchive.Service.NovelService.Models.Localization;
using FictionArchive.Service.Shared.Models;
using NovelStatus = FictionArchive.Service.NovelService.Models.Enums.NovelStatus;
@@ -22,4 +23,5 @@ public class Novel : BaseEntity<uint>
public List<Chapter> Chapters { get; set; }
public List<NovelTag> Tags { get; set; }
public Image? CoverImage { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace FictionArchive.Service.NovelService.Models.SourceAdapters;
public class ChapterFetchResult
{
public string Text { get; set; }
public List<ImageData> ImageData { get; set; }
}

View File

@@ -6,5 +6,5 @@ public class ChapterMetadata
public uint Order { get; set; }
public string? Url { get; set; }
public string Name { get; set; }
public List<string> ImageUrls { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace FictionArchive.Service.NovelService.Models.SourceAdapters;
public class ImageData
{
public string Url { get; set; }
public byte[] Data { get; set; }
}

View File

@@ -11,6 +11,7 @@ public class NovelMetadata
public string AuthorUrl { get; set; }
public string Url { get; set; }
public string ExternalId { get; set; }
public ImageData? CoverImage { get; set; }
public Language RawLanguage { get; set; }
public NovelStatus RawStatus { get; set; }

View File

@@ -1,4 +1,6 @@
using FictionArchive.Common.Extensions;
using FictionArchive.Service.NovelService.GraphQL;
using FictionArchive.Service.NovelService.Models.Configuration;
using FictionArchive.Service.NovelService.Models.IntegrationEvents;
using FictionArchive.Service.NovelService.Services;
using FictionArchive.Service.NovelService.Services.EventHandlers;
@@ -16,6 +18,7 @@ public class Program
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.AddLocalAppsettings();
builder.Services.AddMemoryCache();
@@ -27,7 +30,8 @@ public class Program
})
.Subscribe<TranslationRequestCompletedEvent, TranslationRequestCompletedEventHandler>()
.Subscribe<NovelUpdateRequestedEvent, NovelUpdateRequestedEventHandler>()
.Subscribe<ChapterPullRequestedEvent, ChapterPullRequestedEventHandler>();
.Subscribe<ChapterPullRequestedEvent, ChapterPullRequestedEventHandler>()
.Subscribe<FileUploadRequestStatusUpdateEvent, FileUploadRequestStatusUpdateEventHandler>();
#endregion
@@ -56,6 +60,7 @@ public class Program
})
.AddHttpMessageHandler<NovelpiaAuthMessageHandler>();
builder.Services.Configure<NovelUpdateServiceConfiguration>(builder.Configuration.GetSection("UpdateService"));
builder.Services.AddTransient<NovelUpdateService>();
#endregion
@@ -77,6 +82,6 @@ public class Program
app.MapGraphQL();
app.Run();
app.RunWithGraphQLCommands(args);
}
}

View File

@@ -0,0 +1,39 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.NovelService.Models.IntegrationEvents;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Services.EventHandlers;
public class FileUploadRequestStatusUpdateEventHandler : IIntegrationEventHandler<FileUploadRequestStatusUpdateEvent>
{
private readonly ILogger<FileUploadRequestStatusUpdateEventHandler> _logger;
private readonly NovelServiceDbContext _context;
private readonly NovelUpdateService _novelUpdateService;
public FileUploadRequestStatusUpdateEventHandler(ILogger<FileUploadRequestStatusUpdateEventHandler> logger, NovelServiceDbContext context, NovelUpdateService novelUpdateService)
{
_logger = logger;
_context = context;
_novelUpdateService = novelUpdateService;
}
public async Task Handle(FileUploadRequestStatusUpdateEvent @event)
{
var image = await _context.Images.FindAsync(@event.RequestId);
if (image == null)
{
// Not a request we care about.
return;
}
if (@event.Status == RequestStatus.Failed)
{
_logger.LogError("Image upload failed for image with id {imageId}", image.Id);
return;
}
else if (@event.Status == RequestStatus.Success)
{
_logger.LogInformation("Image upload succeeded for image with id {imageId}", image.Id);
await _novelUpdateService.UpdateImage(image.Id, @event.FileAccessUrl);
}
}
}

View File

@@ -1,3 +1,4 @@
using FictionArchive.Service.NovelService.Models.Images;
using FictionArchive.Service.NovelService.Models.Localization;
using FictionArchive.Service.NovelService.Models.Novels;
using FictionArchive.Service.Shared.Services.Database;
@@ -14,4 +15,5 @@ public class NovelServiceDbContext(DbContextOptions options, ILogger<NovelServic
public DbSet<NovelTag> Tags { get; set; }
public DbSet<LocalizationKey> LocalizationKeys { get; set; }
public DbSet<LocalizationRequest> LocalizationRequests { get; set; }
public DbSet<Image> Images { get; set; }
}

View File

@@ -1,9 +1,15 @@
using FictionArchive.Service.FileService.IntegrationEvents;
using FictionArchive.Service.NovelService.Models.Configuration;
using FictionArchive.Service.NovelService.Models.Enums;
using FictionArchive.Service.NovelService.Models.Images;
using FictionArchive.Service.NovelService.Models.Localization;
using FictionArchive.Service.NovelService.Models.Novels;
using FictionArchive.Service.NovelService.Models.SourceAdapters;
using FictionArchive.Service.NovelService.Services.SourceAdapters;
using FictionArchive.Service.Shared.Services.EventBus;
using HtmlAgilityPack;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace FictionArchive.Service.NovelService.Services;
@@ -12,12 +18,16 @@ public class NovelUpdateService
private readonly NovelServiceDbContext _dbContext;
private readonly ILogger<NovelUpdateService> _logger;
private readonly IEnumerable<ISourceAdapter> _sourceAdapters;
private readonly IEventBus _eventBus;
private readonly NovelUpdateServiceConfiguration _novelUpdateServiceConfiguration;
public NovelUpdateService(NovelServiceDbContext dbContext, ILogger<NovelUpdateService> logger, IEnumerable<ISourceAdapter> sourceAdapters)
public NovelUpdateService(NovelServiceDbContext dbContext, ILogger<NovelUpdateService> logger, IEnumerable<ISourceAdapter> sourceAdapters, IEventBus eventBus, IOptions<NovelUpdateServiceConfiguration> novelUpdateServiceConfiguration)
{
_dbContext = dbContext;
_logger = logger;
_sourceAdapters = sourceAdapters;
_eventBus = eventBus;
_novelUpdateServiceConfiguration = novelUpdateServiceConfiguration.Value;
}
public async Task<Novel> ImportNovel(string novelUrl)
@@ -59,6 +69,10 @@ public class NovelUpdateService
RawLanguage = metadata.RawLanguage,
Url = metadata.Url,
ExternalId = metadata.ExternalId,
CoverImage = metadata.CoverImage != null ? new Image()
{
OriginalPath = metadata.CoverImage.Url,
} : null,
Chapters = metadata.Chapters.Select(chapter =>
{
return new Chapter()
@@ -85,7 +99,18 @@ public class NovelUpdateService
}
});
await _dbContext.SaveChangesAsync();
// Signal request for cover image if present
if (addedNovel.Entity.CoverImage != null)
{
await _eventBus.Publish(new FileUploadRequestCreatedEvent()
{
RequestId = addedNovel.Entity.CoverImage.Id,
FileData = metadata.CoverImage.Data,
FilePath = $"Novels/{addedNovel.Entity.Id}/Images/cover.jpg"
});
}
return addedNovel.Entity;
}
@@ -95,17 +120,85 @@ public class NovelUpdateService
.Include(novel => novel.Chapters)
.ThenInclude(chapter => chapter.Body)
.ThenInclude(body => body.Texts)
.Include(novel => novel.Source)
.Include(novel => novel.Source).Include(novel => novel.Chapters).ThenInclude(chapter => chapter.Images)
.FirstOrDefaultAsync();
var chapter = novel.Chapters.Where(chapter => chapter.Order == chapterNumber).FirstOrDefault();
var adapter = _sourceAdapters.FirstOrDefault(adapter => adapter.SourceDescriptor.Key == novel.Source.Key);
var rawChapter = await adapter.GetRawChapter(chapter.Url);
chapter.Body.Texts.Add(new LocalizationText()
var localizationText = new LocalizationText()
{
Text = rawChapter,
Text = rawChapter.Text,
Language = novel.RawLanguage
});
};
chapter.Body.Texts.Add(localizationText);
chapter.Images = rawChapter.ImageData.Select(img => new Image()
{
OriginalPath = img.Url
}).ToList();
await _dbContext.SaveChangesAsync();
// Images are saved and have ids, update the chapter body to replace image tags
var chapterDoc = new HtmlDocument();
chapterDoc.LoadHtml(rawChapter.Text);
foreach (var image in chapter.Images)
{
var match = chapterDoc.DocumentNode.SelectSingleNode(@$"//img[@src='{image.OriginalPath}']");
if (match != null)
{
match.Attributes["src"].Value = _novelUpdateServiceConfiguration.PendingImageUrl;
if (match.Attributes.Contains("alt"))
{
match.Attributes["alt"].Value = image.Id.ToString();
}
else
{
match.Attributes.Add("alt", image.Id.ToString());
}
}
}
localizationText.Text = chapterDoc.DocumentNode.OuterHtml;
await _dbContext.SaveChangesAsync();
// Body was updated, raise image request
int imgCount = 0;
foreach (var image in chapter.Images)
{
var data = rawChapter.ImageData.FirstOrDefault(img => img.Url == image.OriginalPath);
await _eventBus.Publish(new FileUploadRequestCreatedEvent()
{
FileData = data.Data,
FilePath = $"{novel.Id}/Images/Chapter-{chapter.Id}/{imgCount++}.jpg",
RequestId = image.Id
});
}
return chapter;
}
}
public async Task UpdateImage(Guid imageId, string newUrl)
{
var image = await _dbContext.Images
.Include(img => img.Chapter)
.ThenInclude(chapter => chapter.Body)
.ThenInclude(body => body.Texts)
.FirstOrDefaultAsync(image => image.Id == imageId);
image.NewPath = newUrl;
// If this is an image from a chapter, let's update the chapter body(s)
if (image.Chapter != null)
{
foreach (var bodyText in image.Chapter.Body.Texts)
{
var chapterDoc = new HtmlDocument();
chapterDoc.LoadHtml(bodyText.Text);
var match = chapterDoc.DocumentNode.SelectSingleNode(@$"//img[@alt='{image.Id}']");
if (match != null)
{
match.Attributes["src"].Value = newUrl;
}
}
}
await _dbContext.SaveChangesAsync();
}
}

View File

@@ -8,5 +8,5 @@ public interface ISourceAdapter
public SourceDescriptor SourceDescriptor { get; }
public Task<bool> CanProcessNovel(string url);
public Task<NovelMetadata> GetMetadata(string novelUrl);
public Task<string> GetRawChapter(string chapterUrl);
public Task<ChapterFetchResult> GetRawChapter(string chapterUrl);
}

View File

@@ -81,6 +81,21 @@ public class NovelpiaAdapter : ISourceAdapter
novel.AuthorName = authorMatch.Groups[2].Value;
novel.AuthorUrl = authorMatch.Groups[2].Value;
// Cover image URL
var coverMatch = Regex.Match(novelData, @"href=""(//images\.novelpia\.com/imagebox/cover/.+?\.file)""");
string coverImageUrl = coverMatch.Groups[1].Value;
if (string.IsNullOrEmpty(coverImageUrl))
{
coverMatch = Regex.Match(novelData, @"src=""(//images\.novelpia\.com/imagebox/cover/.+?\.file)""");
coverImageUrl = coverMatch.Groups[1].Value;
}
novel.CoverImage = new ImageData()
{
Url = coverImageUrl,
Data = await GetImageData(coverImageUrl),
};
// Some badge info
var badgeSet = Regex.Match(novelData, @"(?s)<p\s+class=""in-badge"">(.*?)<\/p>");
var badgeMatches = Regex.Matches(badgeSet.Groups[1].Value, @"<span[^>]*>(.*?)<\/span>");
@@ -160,7 +175,7 @@ public class NovelpiaAdapter : ISourceAdapter
return novel;
}
public async Task<string> GetRawChapter(string chapterUrl)
public async Task<ChapterFetchResult> GetRawChapter(string chapterUrl)
{
var chapterId = uint.Parse(Regex.Match(chapterUrl, ChapterIdRegex).Groups[1].Value);
var endpoint = ChapterDownloadEndpoint + chapterId;
@@ -171,6 +186,11 @@ public class NovelpiaAdapter : ISourceAdapter
{
throw new Exception();
}
var fetchResult = new ChapterFetchResult()
{
ImageData = new List<ImageData>()
};
StringBuilder builder = new StringBuilder();
using var doc = JsonDocument.Parse(responseContent);
@@ -182,10 +202,20 @@ public class NovelpiaAdapter : ISourceAdapter
foreach (JsonElement item in sArray.EnumerateArray())
{
string text = item.GetProperty("text").GetString();
var imageMatch = Regex.Match(text, @"<img.+?src=\""(.+?)\"".+?>");
if (text.Contains("cover-wrapper"))
{
continue;
}
if (imageMatch.Success)
{
var url = imageMatch.Groups[1].Value;
fetchResult.ImageData.Add(new ImageData()
{
Url = url,
Data = await GetImageData(url)
});
}
if (text.Contains("opacity: 0"))
{
continue;
@@ -193,8 +223,24 @@ public class NovelpiaAdapter : ISourceAdapter
builder.Append(WebUtility.HtmlDecode(text));
}
fetchResult.Text = builder.ToString();
return builder.ToString();
return fetchResult;
}
private async Task<byte[]> GetImageData(string url)
{
if (!url.StartsWith("http"))
{
url = "https:" + url;
}
var image = await _httpClient.GetAsync(url);
if (!image.IsSuccessStatusCode)
{
_logger.LogError("Attempting to fetch image with url {imgUrl} returned status code {code}.", url, image.StatusCode);
throw new Exception();
}
return await image.Content.ReadAsByteArrayAsync();
}
}

View File

@@ -52,10 +52,15 @@ public class NovelpiaAuthMessageHandler : DelegatingHandler
var response = await _httpClient.SendAsync(loginMessage);
using (var streamReader = new StreamReader(response.Content.ReadAsStream()))
{
if (streamReader.ReadToEnd().Contains(LoginSuccessMessage))
var message = await streamReader.ReadToEndAsync();
if (message.Contains(LoginSuccessMessage))
{
_cache.Set(CacheKey, loginKey);
}
else
{
throw new Exception("An error occured while retrieving the login key. Message: " + message);
}
}
}

View File

@@ -9,6 +9,9 @@
"Username": "REPLACE_ME",
"Password": "REPLACE_ME"
},
"UpdateService": {
"PendingImageUrl": "https://localhost:7247/api/pendingupload.png"
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=FictionArchive_NovelService;Username=postgres;password=postgres"
},

View File

@@ -0,0 +1,6 @@
{
"subgraph": "Novels",
"http": {
"baseAddress": "http://localhost:5101/graphql"
}
}

View File

@@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["FictionArchive.Service.SchedulerService/FictionArchive.Service.SchedulerService.csproj", "FictionArchive.Service.SchedulerService/"]
RUN dotnet restore "FictionArchive.Service.SchedulerService/FictionArchive.Service.SchedulerService.csproj"
COPY . .
WORKDIR "/src/FictionArchive.Service.SchedulerService"
RUN dotnet build "./FictionArchive.Service.SchedulerService.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./FictionArchive.Service.SchedulerService.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "FictionArchive.Service.SchedulerService.dll"]

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FictionArchive.Service.Shared\FictionArchive.Service.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AppAny.Quartz.EntityFrameworkCore.Migrations.PostgreSQL" Version="0.5.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Quartz" Version="3.15.1" />
<PackageReference Include="Quartz.AspNetCore" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.15.1" />
<PackageReference Include="Quartz.Serialization.Json" Version="3.15.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,35 @@
using System.Data;
using FictionArchive.Service.SchedulerService.Models;
using FictionArchive.Service.SchedulerService.Services;
using HotChocolate.Types;
using Quartz;
namespace FictionArchive.Service.SchedulerService.GraphQL;
public class Mutation
{
[Error<DuplicateNameException>]
[Error<FormatException>]
public async Task<SchedulerJob> ScheduleEventJob(string key, string description, string eventType, string eventData, string cronSchedule, JobManagerService jobManager)
{
return await jobManager.ScheduleEventJob(key, description, eventType, eventData, cronSchedule);
}
[Error<JobPersistenceException>]
public async Task<bool> RunJob(string jobKey, JobManagerService jobManager)
{
return await jobManager.TriggerJob(jobKey);
}
[Error<KeyNotFoundException>]
public async Task<bool> DeleteJob(string jobKey, JobManagerService jobManager)
{
bool deleted = await jobManager.DeleteJob(jobKey);
if (!deleted)
{
throw new KeyNotFoundException($"Job with key '{jobKey}' was not found");
}
return true;
}
}

View File

@@ -0,0 +1,15 @@
using FictionArchive.Service.SchedulerService.Models;
using FictionArchive.Service.SchedulerService.Services;
using HotChocolate;
using Quartz;
using Quartz.Impl.Matchers;
namespace FictionArchive.Service.SchedulerService.GraphQL;
public class Query
{
public async Task<IEnumerable<SchedulerJob>> GetJobs(JobManagerService jobManager)
{
return await jobManager.GetScheduledJobs();
}
}

View File

@@ -0,0 +1,543 @@
// <auto-generated />
using FictionArchive.Service.SchedulerService.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FictionArchive.Service.SchedulerService.Migrations
{
[DbContext(typeof(SchedulerServiceDbContext))]
[Migration("20251120151130_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("TriggerName")
.HasColumnType("text")
.HasColumnName("trigger_name");
b.Property<string>("TriggerGroup")
.HasColumnType("text")
.HasColumnName("trigger_group");
b.Property<byte[]>("BlobData")
.HasColumnType("bytea")
.HasColumnName("blob_data");
b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
b.ToTable("qrtz_blob_triggers", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCalendar", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("CalendarName")
.HasColumnType("text")
.HasColumnName("calendar_name");
b.Property<byte[]>("Calendar")
.IsRequired()
.HasColumnType("bytea")
.HasColumnName("calendar");
b.HasKey("SchedulerName", "CalendarName");
b.ToTable("qrtz_calendars", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("TriggerName")
.HasColumnType("text")
.HasColumnName("trigger_name");
b.Property<string>("TriggerGroup")
.HasColumnType("text")
.HasColumnName("trigger_group");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("text")
.HasColumnName("cron_expression");
b.Property<string>("TimeZoneId")
.HasColumnType("text")
.HasColumnName("time_zone_id");
b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
b.ToTable("qrtz_cron_triggers", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzFiredTrigger", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("EntryId")
.HasColumnType("text")
.HasColumnName("entry_id");
b.Property<long>("FiredTime")
.HasColumnType("bigint")
.HasColumnName("fired_time");
b.Property<string>("InstanceName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("instance_name");
b.Property<bool>("IsNonConcurrent")
.HasColumnType("bool")
.HasColumnName("is_nonconcurrent");
b.Property<string>("JobGroup")
.HasColumnType("text")
.HasColumnName("job_group");
b.Property<string>("JobName")
.HasColumnType("text")
.HasColumnName("job_name");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<bool?>("RequestsRecovery")
.HasColumnType("bool")
.HasColumnName("requests_recovery");
b.Property<long>("ScheduledTime")
.HasColumnType("bigint")
.HasColumnName("sched_time");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text")
.HasColumnName("state");
b.Property<string>("TriggerGroup")
.IsRequired()
.HasColumnType("text")
.HasColumnName("trigger_group");
b.Property<string>("TriggerName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("trigger_name");
b.HasKey("SchedulerName", "EntryId");
b.HasIndex("InstanceName")
.HasDatabaseName("idx_qrtz_ft_trig_inst_name");
b.HasIndex("JobGroup")
.HasDatabaseName("idx_qrtz_ft_job_group");
b.HasIndex("JobName")
.HasDatabaseName("idx_qrtz_ft_job_name");
b.HasIndex("RequestsRecovery")
.HasDatabaseName("idx_qrtz_ft_job_req_recovery");
b.HasIndex("TriggerGroup")
.HasDatabaseName("idx_qrtz_ft_trig_group");
b.HasIndex("TriggerName")
.HasDatabaseName("idx_qrtz_ft_trig_name");
b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup")
.HasDatabaseName("idx_qrtz_ft_trig_nm_gp");
b.ToTable("qrtz_fired_triggers", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("JobName")
.HasColumnType("text")
.HasColumnName("job_name");
b.Property<string>("JobGroup")
.HasColumnType("text")
.HasColumnName("job_group");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<bool>("IsDurable")
.HasColumnType("bool")
.HasColumnName("is_durable");
b.Property<bool>("IsNonConcurrent")
.HasColumnType("bool")
.HasColumnName("is_nonconcurrent");
b.Property<bool>("IsUpdateData")
.HasColumnType("bool")
.HasColumnName("is_update_data");
b.Property<string>("JobClassName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("job_class_name");
b.Property<byte[]>("JobData")
.HasColumnType("bytea")
.HasColumnName("job_data");
b.Property<bool>("RequestsRecovery")
.HasColumnType("bool")
.HasColumnName("requests_recovery");
b.HasKey("SchedulerName", "JobName", "JobGroup");
b.HasIndex("RequestsRecovery")
.HasDatabaseName("idx_qrtz_j_req_recovery");
b.ToTable("qrtz_job_details", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzLock", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("LockName")
.HasColumnType("text")
.HasColumnName("lock_name");
b.HasKey("SchedulerName", "LockName");
b.ToTable("qrtz_locks", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzPausedTriggerGroup", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("TriggerGroup")
.HasColumnType("text")
.HasColumnName("trigger_group");
b.HasKey("SchedulerName", "TriggerGroup");
b.ToTable("qrtz_paused_trigger_grps", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSchedulerState", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("InstanceName")
.HasColumnType("text")
.HasColumnName("instance_name");
b.Property<long>("CheckInInterval")
.HasColumnType("bigint")
.HasColumnName("checkin_interval");
b.Property<long>("LastCheckInTime")
.HasColumnType("bigint")
.HasColumnName("last_checkin_time");
b.HasKey("SchedulerName", "InstanceName");
b.ToTable("qrtz_scheduler_state", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("TriggerName")
.HasColumnType("text")
.HasColumnName("trigger_name");
b.Property<string>("TriggerGroup")
.HasColumnType("text")
.HasColumnName("trigger_group");
b.Property<bool?>("BooleanProperty1")
.HasColumnType("bool")
.HasColumnName("bool_prop_1");
b.Property<bool?>("BooleanProperty2")
.HasColumnType("bool")
.HasColumnName("bool_prop_2");
b.Property<decimal?>("DecimalProperty1")
.HasColumnType("numeric")
.HasColumnName("dec_prop_1");
b.Property<decimal?>("DecimalProperty2")
.HasColumnType("numeric")
.HasColumnName("dec_prop_2");
b.Property<int?>("IntegerProperty1")
.HasColumnType("integer")
.HasColumnName("int_prop_1");
b.Property<int?>("IntegerProperty2")
.HasColumnType("integer")
.HasColumnName("int_prop_2");
b.Property<long?>("LongProperty1")
.HasColumnType("bigint")
.HasColumnName("long_prop_1");
b.Property<long?>("LongProperty2")
.HasColumnType("bigint")
.HasColumnName("long_prop_2");
b.Property<string>("StringProperty1")
.HasColumnType("text")
.HasColumnName("str_prop_1");
b.Property<string>("StringProperty2")
.HasColumnType("text")
.HasColumnName("str_prop_2");
b.Property<string>("StringProperty3")
.HasColumnType("text")
.HasColumnName("str_prop_3");
b.Property<string>("TimeZoneId")
.HasColumnType("text")
.HasColumnName("time_zone_id");
b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
b.ToTable("qrtz_simprop_triggers", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("TriggerName")
.HasColumnType("text")
.HasColumnName("trigger_name");
b.Property<string>("TriggerGroup")
.HasColumnType("text")
.HasColumnName("trigger_group");
b.Property<long>("RepeatCount")
.HasColumnType("bigint")
.HasColumnName("repeat_count");
b.Property<long>("RepeatInterval")
.HasColumnType("bigint")
.HasColumnName("repeat_interval");
b.Property<long>("TimesTriggered")
.HasColumnType("bigint")
.HasColumnName("times_triggered");
b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
b.ToTable("qrtz_simple_triggers", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("TriggerName")
.HasColumnType("text")
.HasColumnName("trigger_name");
b.Property<string>("TriggerGroup")
.HasColumnType("text")
.HasColumnName("trigger_group");
b.Property<string>("CalendarName")
.HasColumnType("text")
.HasColumnName("calendar_name");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<long?>("EndTime")
.HasColumnType("bigint")
.HasColumnName("end_time");
b.Property<byte[]>("JobData")
.HasColumnType("bytea")
.HasColumnName("job_data");
b.Property<string>("JobGroup")
.IsRequired()
.HasColumnType("text")
.HasColumnName("job_group");
b.Property<string>("JobName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("job_name");
b.Property<short?>("MisfireInstruction")
.HasColumnType("smallint")
.HasColumnName("misfire_instr");
b.Property<long?>("NextFireTime")
.HasColumnType("bigint")
.HasColumnName("next_fire_time");
b.Property<long?>("PreviousFireTime")
.HasColumnType("bigint")
.HasColumnName("prev_fire_time");
b.Property<int?>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<long>("StartTime")
.HasColumnType("bigint")
.HasColumnName("start_time");
b.Property<string>("TriggerState")
.IsRequired()
.HasColumnType("text")
.HasColumnName("trigger_state");
b.Property<string>("TriggerType")
.IsRequired()
.HasColumnType("text")
.HasColumnName("trigger_type");
b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
b.HasIndex("NextFireTime")
.HasDatabaseName("idx_qrtz_t_next_fire_time");
b.HasIndex("TriggerState")
.HasDatabaseName("idx_qrtz_t_state");
b.HasIndex("NextFireTime", "TriggerState")
.HasDatabaseName("idx_qrtz_t_nft_st");
b.HasIndex("SchedulerName", "JobName", "JobGroup");
b.ToTable("qrtz_triggers", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b =>
{
b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
.WithMany("BlobTriggers")
.HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trigger");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b =>
{
b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
.WithMany("CronTriggers")
.HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trigger");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b =>
{
b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
.WithMany("SimplePropertyTriggers")
.HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trigger");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b =>
{
b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
.WithMany("SimpleTriggers")
.HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trigger");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b =>
{
b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", "JobDetail")
.WithMany("Triggers")
.HasForeignKey("SchedulerName", "JobName", "JobGroup")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobDetail");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b =>
{
b.Navigation("Triggers");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b =>
{
b.Navigation("BlobTriggers");
b.Navigation("CronTriggers");
b.Navigation("SimplePropertyTriggers");
b.Navigation("SimpleTriggers");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,373 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FictionArchive.Service.SchedulerService.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "quartz");
migrationBuilder.CreateTable(
name: "qrtz_calendars",
schema: "quartz",
columns: table => new
{
sched_name = table.Column<string>(type: "text", nullable: false),
calendar_name = table.Column<string>(type: "text", nullable: false),
calendar = table.Column<byte[]>(type: "bytea", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_qrtz_calendars", x => new { x.sched_name, x.calendar_name });
});
migrationBuilder.CreateTable(
name: "qrtz_fired_triggers",
schema: "quartz",
columns: table => new
{
sched_name = table.Column<string>(type: "text", nullable: false),
entry_id = table.Column<string>(type: "text", nullable: false),
trigger_name = table.Column<string>(type: "text", nullable: false),
trigger_group = table.Column<string>(type: "text", nullable: false),
instance_name = table.Column<string>(type: "text", nullable: false),
fired_time = table.Column<long>(type: "bigint", nullable: false),
sched_time = table.Column<long>(type: "bigint", nullable: false),
priority = table.Column<int>(type: "integer", nullable: false),
state = table.Column<string>(type: "text", nullable: false),
job_name = table.Column<string>(type: "text", nullable: true),
job_group = table.Column<string>(type: "text", nullable: true),
is_nonconcurrent = table.Column<bool>(type: "bool", nullable: false),
requests_recovery = table.Column<bool>(type: "bool", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_qrtz_fired_triggers", x => new { x.sched_name, x.entry_id });
});
migrationBuilder.CreateTable(
name: "qrtz_job_details",
schema: "quartz",
columns: table => new
{
sched_name = table.Column<string>(type: "text", nullable: false),
job_name = table.Column<string>(type: "text", nullable: false),
job_group = table.Column<string>(type: "text", nullable: false),
description = table.Column<string>(type: "text", nullable: true),
job_class_name = table.Column<string>(type: "text", nullable: false),
is_durable = table.Column<bool>(type: "bool", nullable: false),
is_nonconcurrent = table.Column<bool>(type: "bool", nullable: false),
is_update_data = table.Column<bool>(type: "bool", nullable: false),
requests_recovery = table.Column<bool>(type: "bool", nullable: false),
job_data = table.Column<byte[]>(type: "bytea", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_qrtz_job_details", x => new { x.sched_name, x.job_name, x.job_group });
});
migrationBuilder.CreateTable(
name: "qrtz_locks",
schema: "quartz",
columns: table => new
{
sched_name = table.Column<string>(type: "text", nullable: false),
lock_name = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_qrtz_locks", x => new { x.sched_name, x.lock_name });
});
migrationBuilder.CreateTable(
name: "qrtz_paused_trigger_grps",
schema: "quartz",
columns: table => new
{
sched_name = table.Column<string>(type: "text", nullable: false),
trigger_group = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_qrtz_paused_trigger_grps", x => new { x.sched_name, x.trigger_group });
});
migrationBuilder.CreateTable(
name: "qrtz_scheduler_state",
schema: "quartz",
columns: table => new
{
sched_name = table.Column<string>(type: "text", nullable: false),
instance_name = table.Column<string>(type: "text", nullable: false),
last_checkin_time = table.Column<long>(type: "bigint", nullable: false),
checkin_interval = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_qrtz_scheduler_state", x => new { x.sched_name, x.instance_name });
});
migrationBuilder.CreateTable(
name: "qrtz_triggers",
schema: "quartz",
columns: table => new
{
sched_name = table.Column<string>(type: "text", nullable: false),
trigger_name = table.Column<string>(type: "text", nullable: false),
trigger_group = table.Column<string>(type: "text", nullable: false),
job_name = table.Column<string>(type: "text", nullable: false),
job_group = table.Column<string>(type: "text", nullable: false),
description = table.Column<string>(type: "text", nullable: true),
next_fire_time = table.Column<long>(type: "bigint", nullable: true),
prev_fire_time = table.Column<long>(type: "bigint", nullable: true),
priority = table.Column<int>(type: "integer", nullable: true),
trigger_state = table.Column<string>(type: "text", nullable: false),
trigger_type = table.Column<string>(type: "text", nullable: false),
start_time = table.Column<long>(type: "bigint", nullable: false),
end_time = table.Column<long>(type: "bigint", nullable: true),
calendar_name = table.Column<string>(type: "text", nullable: true),
misfire_instr = table.Column<short>(type: "smallint", nullable: true),
job_data = table.Column<byte[]>(type: "bytea", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_qrtz_triggers", x => new { x.sched_name, x.trigger_name, x.trigger_group });
table.ForeignKey(
name: "FK_qrtz_triggers_qrtz_job_details_sched_name_job_name_job_group",
columns: x => new { x.sched_name, x.job_name, x.job_group },
principalSchema: "quartz",
principalTable: "qrtz_job_details",
principalColumns: new[] { "sched_name", "job_name", "job_group" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "qrtz_blob_triggers",
schema: "quartz",
columns: table => new
{
sched_name = table.Column<string>(type: "text", nullable: false),
trigger_name = table.Column<string>(type: "text", nullable: false),
trigger_group = table.Column<string>(type: "text", nullable: false),
blob_data = table.Column<byte[]>(type: "bytea", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_qrtz_blob_triggers", x => new { x.sched_name, x.trigger_name, x.trigger_group });
table.ForeignKey(
name: "FK_qrtz_blob_triggers_qrtz_triggers_sched_name_trigger_name_tr~",
columns: x => new { x.sched_name, x.trigger_name, x.trigger_group },
principalSchema: "quartz",
principalTable: "qrtz_triggers",
principalColumns: new[] { "sched_name", "trigger_name", "trigger_group" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "qrtz_cron_triggers",
schema: "quartz",
columns: table => new
{
sched_name = table.Column<string>(type: "text", nullable: false),
trigger_name = table.Column<string>(type: "text", nullable: false),
trigger_group = table.Column<string>(type: "text", nullable: false),
cron_expression = table.Column<string>(type: "text", nullable: false),
time_zone_id = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_qrtz_cron_triggers", x => new { x.sched_name, x.trigger_name, x.trigger_group });
table.ForeignKey(
name: "FK_qrtz_cron_triggers_qrtz_triggers_sched_name_trigger_name_tr~",
columns: x => new { x.sched_name, x.trigger_name, x.trigger_group },
principalSchema: "quartz",
principalTable: "qrtz_triggers",
principalColumns: new[] { "sched_name", "trigger_name", "trigger_group" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "qrtz_simple_triggers",
schema: "quartz",
columns: table => new
{
sched_name = table.Column<string>(type: "text", nullable: false),
trigger_name = table.Column<string>(type: "text", nullable: false),
trigger_group = table.Column<string>(type: "text", nullable: false),
repeat_count = table.Column<long>(type: "bigint", nullable: false),
repeat_interval = table.Column<long>(type: "bigint", nullable: false),
times_triggered = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_qrtz_simple_triggers", x => new { x.sched_name, x.trigger_name, x.trigger_group });
table.ForeignKey(
name: "FK_qrtz_simple_triggers_qrtz_triggers_sched_name_trigger_name_~",
columns: x => new { x.sched_name, x.trigger_name, x.trigger_group },
principalSchema: "quartz",
principalTable: "qrtz_triggers",
principalColumns: new[] { "sched_name", "trigger_name", "trigger_group" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "qrtz_simprop_triggers",
schema: "quartz",
columns: table => new
{
sched_name = table.Column<string>(type: "text", nullable: false),
trigger_name = table.Column<string>(type: "text", nullable: false),
trigger_group = table.Column<string>(type: "text", nullable: false),
str_prop_1 = table.Column<string>(type: "text", nullable: true),
str_prop_2 = table.Column<string>(type: "text", nullable: true),
str_prop_3 = table.Column<string>(type: "text", nullable: true),
int_prop_1 = table.Column<int>(type: "integer", nullable: true),
int_prop_2 = table.Column<int>(type: "integer", nullable: true),
long_prop_1 = table.Column<long>(type: "bigint", nullable: true),
long_prop_2 = table.Column<long>(type: "bigint", nullable: true),
dec_prop_1 = table.Column<decimal>(type: "numeric", nullable: true),
dec_prop_2 = table.Column<decimal>(type: "numeric", nullable: true),
bool_prop_1 = table.Column<bool>(type: "bool", nullable: true),
bool_prop_2 = table.Column<bool>(type: "bool", nullable: true),
time_zone_id = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_qrtz_simprop_triggers", x => new { x.sched_name, x.trigger_name, x.trigger_group });
table.ForeignKey(
name: "FK_qrtz_simprop_triggers_qrtz_triggers_sched_name_trigger_name~",
columns: x => new { x.sched_name, x.trigger_name, x.trigger_group },
principalSchema: "quartz",
principalTable: "qrtz_triggers",
principalColumns: new[] { "sched_name", "trigger_name", "trigger_group" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "idx_qrtz_ft_job_group",
schema: "quartz",
table: "qrtz_fired_triggers",
column: "job_group");
migrationBuilder.CreateIndex(
name: "idx_qrtz_ft_job_name",
schema: "quartz",
table: "qrtz_fired_triggers",
column: "job_name");
migrationBuilder.CreateIndex(
name: "idx_qrtz_ft_job_req_recovery",
schema: "quartz",
table: "qrtz_fired_triggers",
column: "requests_recovery");
migrationBuilder.CreateIndex(
name: "idx_qrtz_ft_trig_group",
schema: "quartz",
table: "qrtz_fired_triggers",
column: "trigger_group");
migrationBuilder.CreateIndex(
name: "idx_qrtz_ft_trig_inst_name",
schema: "quartz",
table: "qrtz_fired_triggers",
column: "instance_name");
migrationBuilder.CreateIndex(
name: "idx_qrtz_ft_trig_name",
schema: "quartz",
table: "qrtz_fired_triggers",
column: "trigger_name");
migrationBuilder.CreateIndex(
name: "idx_qrtz_ft_trig_nm_gp",
schema: "quartz",
table: "qrtz_fired_triggers",
columns: new[] { "sched_name", "trigger_name", "trigger_group" });
migrationBuilder.CreateIndex(
name: "idx_qrtz_j_req_recovery",
schema: "quartz",
table: "qrtz_job_details",
column: "requests_recovery");
migrationBuilder.CreateIndex(
name: "idx_qrtz_t_next_fire_time",
schema: "quartz",
table: "qrtz_triggers",
column: "next_fire_time");
migrationBuilder.CreateIndex(
name: "idx_qrtz_t_nft_st",
schema: "quartz",
table: "qrtz_triggers",
columns: new[] { "next_fire_time", "trigger_state" });
migrationBuilder.CreateIndex(
name: "idx_qrtz_t_state",
schema: "quartz",
table: "qrtz_triggers",
column: "trigger_state");
migrationBuilder.CreateIndex(
name: "IX_qrtz_triggers_sched_name_job_name_job_group",
schema: "quartz",
table: "qrtz_triggers",
columns: new[] { "sched_name", "job_name", "job_group" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "qrtz_blob_triggers",
schema: "quartz");
migrationBuilder.DropTable(
name: "qrtz_calendars",
schema: "quartz");
migrationBuilder.DropTable(
name: "qrtz_cron_triggers",
schema: "quartz");
migrationBuilder.DropTable(
name: "qrtz_fired_triggers",
schema: "quartz");
migrationBuilder.DropTable(
name: "qrtz_locks",
schema: "quartz");
migrationBuilder.DropTable(
name: "qrtz_paused_trigger_grps",
schema: "quartz");
migrationBuilder.DropTable(
name: "qrtz_scheduler_state",
schema: "quartz");
migrationBuilder.DropTable(
name: "qrtz_simple_triggers",
schema: "quartz");
migrationBuilder.DropTable(
name: "qrtz_simprop_triggers",
schema: "quartz");
migrationBuilder.DropTable(
name: "qrtz_triggers",
schema: "quartz");
migrationBuilder.DropTable(
name: "qrtz_job_details",
schema: "quartz");
}
}
}

View File

@@ -0,0 +1,540 @@
// <auto-generated />
using FictionArchive.Service.SchedulerService.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FictionArchive.Service.SchedulerService.Migrations
{
[DbContext(typeof(SchedulerServiceDbContext))]
partial class SchedulerServiceDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("TriggerName")
.HasColumnType("text")
.HasColumnName("trigger_name");
b.Property<string>("TriggerGroup")
.HasColumnType("text")
.HasColumnName("trigger_group");
b.Property<byte[]>("BlobData")
.HasColumnType("bytea")
.HasColumnName("blob_data");
b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
b.ToTable("qrtz_blob_triggers", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCalendar", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("CalendarName")
.HasColumnType("text")
.HasColumnName("calendar_name");
b.Property<byte[]>("Calendar")
.IsRequired()
.HasColumnType("bytea")
.HasColumnName("calendar");
b.HasKey("SchedulerName", "CalendarName");
b.ToTable("qrtz_calendars", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("TriggerName")
.HasColumnType("text")
.HasColumnName("trigger_name");
b.Property<string>("TriggerGroup")
.HasColumnType("text")
.HasColumnName("trigger_group");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("text")
.HasColumnName("cron_expression");
b.Property<string>("TimeZoneId")
.HasColumnType("text")
.HasColumnName("time_zone_id");
b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
b.ToTable("qrtz_cron_triggers", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzFiredTrigger", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("EntryId")
.HasColumnType("text")
.HasColumnName("entry_id");
b.Property<long>("FiredTime")
.HasColumnType("bigint")
.HasColumnName("fired_time");
b.Property<string>("InstanceName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("instance_name");
b.Property<bool>("IsNonConcurrent")
.HasColumnType("bool")
.HasColumnName("is_nonconcurrent");
b.Property<string>("JobGroup")
.HasColumnType("text")
.HasColumnName("job_group");
b.Property<string>("JobName")
.HasColumnType("text")
.HasColumnName("job_name");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<bool?>("RequestsRecovery")
.HasColumnType("bool")
.HasColumnName("requests_recovery");
b.Property<long>("ScheduledTime")
.HasColumnType("bigint")
.HasColumnName("sched_time");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text")
.HasColumnName("state");
b.Property<string>("TriggerGroup")
.IsRequired()
.HasColumnType("text")
.HasColumnName("trigger_group");
b.Property<string>("TriggerName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("trigger_name");
b.HasKey("SchedulerName", "EntryId");
b.HasIndex("InstanceName")
.HasDatabaseName("idx_qrtz_ft_trig_inst_name");
b.HasIndex("JobGroup")
.HasDatabaseName("idx_qrtz_ft_job_group");
b.HasIndex("JobName")
.HasDatabaseName("idx_qrtz_ft_job_name");
b.HasIndex("RequestsRecovery")
.HasDatabaseName("idx_qrtz_ft_job_req_recovery");
b.HasIndex("TriggerGroup")
.HasDatabaseName("idx_qrtz_ft_trig_group");
b.HasIndex("TriggerName")
.HasDatabaseName("idx_qrtz_ft_trig_name");
b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup")
.HasDatabaseName("idx_qrtz_ft_trig_nm_gp");
b.ToTable("qrtz_fired_triggers", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("JobName")
.HasColumnType("text")
.HasColumnName("job_name");
b.Property<string>("JobGroup")
.HasColumnType("text")
.HasColumnName("job_group");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<bool>("IsDurable")
.HasColumnType("bool")
.HasColumnName("is_durable");
b.Property<bool>("IsNonConcurrent")
.HasColumnType("bool")
.HasColumnName("is_nonconcurrent");
b.Property<bool>("IsUpdateData")
.HasColumnType("bool")
.HasColumnName("is_update_data");
b.Property<string>("JobClassName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("job_class_name");
b.Property<byte[]>("JobData")
.HasColumnType("bytea")
.HasColumnName("job_data");
b.Property<bool>("RequestsRecovery")
.HasColumnType("bool")
.HasColumnName("requests_recovery");
b.HasKey("SchedulerName", "JobName", "JobGroup");
b.HasIndex("RequestsRecovery")
.HasDatabaseName("idx_qrtz_j_req_recovery");
b.ToTable("qrtz_job_details", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzLock", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("LockName")
.HasColumnType("text")
.HasColumnName("lock_name");
b.HasKey("SchedulerName", "LockName");
b.ToTable("qrtz_locks", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzPausedTriggerGroup", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("TriggerGroup")
.HasColumnType("text")
.HasColumnName("trigger_group");
b.HasKey("SchedulerName", "TriggerGroup");
b.ToTable("qrtz_paused_trigger_grps", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSchedulerState", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("InstanceName")
.HasColumnType("text")
.HasColumnName("instance_name");
b.Property<long>("CheckInInterval")
.HasColumnType("bigint")
.HasColumnName("checkin_interval");
b.Property<long>("LastCheckInTime")
.HasColumnType("bigint")
.HasColumnName("last_checkin_time");
b.HasKey("SchedulerName", "InstanceName");
b.ToTable("qrtz_scheduler_state", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("TriggerName")
.HasColumnType("text")
.HasColumnName("trigger_name");
b.Property<string>("TriggerGroup")
.HasColumnType("text")
.HasColumnName("trigger_group");
b.Property<bool?>("BooleanProperty1")
.HasColumnType("bool")
.HasColumnName("bool_prop_1");
b.Property<bool?>("BooleanProperty2")
.HasColumnType("bool")
.HasColumnName("bool_prop_2");
b.Property<decimal?>("DecimalProperty1")
.HasColumnType("numeric")
.HasColumnName("dec_prop_1");
b.Property<decimal?>("DecimalProperty2")
.HasColumnType("numeric")
.HasColumnName("dec_prop_2");
b.Property<int?>("IntegerProperty1")
.HasColumnType("integer")
.HasColumnName("int_prop_1");
b.Property<int?>("IntegerProperty2")
.HasColumnType("integer")
.HasColumnName("int_prop_2");
b.Property<long?>("LongProperty1")
.HasColumnType("bigint")
.HasColumnName("long_prop_1");
b.Property<long?>("LongProperty2")
.HasColumnType("bigint")
.HasColumnName("long_prop_2");
b.Property<string>("StringProperty1")
.HasColumnType("text")
.HasColumnName("str_prop_1");
b.Property<string>("StringProperty2")
.HasColumnType("text")
.HasColumnName("str_prop_2");
b.Property<string>("StringProperty3")
.HasColumnType("text")
.HasColumnName("str_prop_3");
b.Property<string>("TimeZoneId")
.HasColumnType("text")
.HasColumnName("time_zone_id");
b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
b.ToTable("qrtz_simprop_triggers", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("TriggerName")
.HasColumnType("text")
.HasColumnName("trigger_name");
b.Property<string>("TriggerGroup")
.HasColumnType("text")
.HasColumnName("trigger_group");
b.Property<long>("RepeatCount")
.HasColumnType("bigint")
.HasColumnName("repeat_count");
b.Property<long>("RepeatInterval")
.HasColumnType("bigint")
.HasColumnName("repeat_interval");
b.Property<long>("TimesTriggered")
.HasColumnType("bigint")
.HasColumnName("times_triggered");
b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
b.ToTable("qrtz_simple_triggers", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b =>
{
b.Property<string>("SchedulerName")
.HasColumnType("text")
.HasColumnName("sched_name");
b.Property<string>("TriggerName")
.HasColumnType("text")
.HasColumnName("trigger_name");
b.Property<string>("TriggerGroup")
.HasColumnType("text")
.HasColumnName("trigger_group");
b.Property<string>("CalendarName")
.HasColumnType("text")
.HasColumnName("calendar_name");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<long?>("EndTime")
.HasColumnType("bigint")
.HasColumnName("end_time");
b.Property<byte[]>("JobData")
.HasColumnType("bytea")
.HasColumnName("job_data");
b.Property<string>("JobGroup")
.IsRequired()
.HasColumnType("text")
.HasColumnName("job_group");
b.Property<string>("JobName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("job_name");
b.Property<short?>("MisfireInstruction")
.HasColumnType("smallint")
.HasColumnName("misfire_instr");
b.Property<long?>("NextFireTime")
.HasColumnType("bigint")
.HasColumnName("next_fire_time");
b.Property<long?>("PreviousFireTime")
.HasColumnType("bigint")
.HasColumnName("prev_fire_time");
b.Property<int?>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<long>("StartTime")
.HasColumnType("bigint")
.HasColumnName("start_time");
b.Property<string>("TriggerState")
.IsRequired()
.HasColumnType("text")
.HasColumnName("trigger_state");
b.Property<string>("TriggerType")
.IsRequired()
.HasColumnType("text")
.HasColumnName("trigger_type");
b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
b.HasIndex("NextFireTime")
.HasDatabaseName("idx_qrtz_t_next_fire_time");
b.HasIndex("TriggerState")
.HasDatabaseName("idx_qrtz_t_state");
b.HasIndex("NextFireTime", "TriggerState")
.HasDatabaseName("idx_qrtz_t_nft_st");
b.HasIndex("SchedulerName", "JobName", "JobGroup");
b.ToTable("qrtz_triggers", "quartz");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b =>
{
b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
.WithMany("BlobTriggers")
.HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trigger");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b =>
{
b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
.WithMany("CronTriggers")
.HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trigger");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b =>
{
b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
.WithMany("SimplePropertyTriggers")
.HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trigger");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b =>
{
b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger")
.WithMany("SimpleTriggers")
.HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trigger");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b =>
{
b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", "JobDetail")
.WithMany("Triggers")
.HasForeignKey("SchedulerName", "JobName", "JobGroup")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobDetail");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b =>
{
b.Navigation("Triggers");
});
modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b =>
{
b.Navigation("BlobTriggers");
b.Navigation("CronTriggers");
b.Navigation("SimplePropertyTriggers");
b.Navigation("SimpleTriggers");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,35 @@
using FictionArchive.Service.Shared.Services.EventBus;
using Newtonsoft.Json;
using Quartz;
namespace FictionArchive.Service.SchedulerService.Models.JobTemplates;
public class EventJobTemplate : IJob
{
private readonly IEventBus _eventBus;
private readonly ILogger<EventJobTemplate> _logger;
public const string EventTypeParameter = "RoutingKey";
public const string EventDataParameter = "MessageData";
public EventJobTemplate(IEventBus eventBus, ILogger<EventJobTemplate> logger)
{
_eventBus = eventBus;
_logger = logger;
}
public async Task Execute(IJobExecutionContext context)
{
try
{
var eventData = context.MergedJobDataMap.GetString(EventDataParameter);
var eventType = context.MergedJobDataMap.GetString(EventTypeParameter);
var eventObject = JsonConvert.DeserializeObject(eventData);
await _eventBus.Publish(eventObject, eventType);
}
catch (Exception ex)
{
_logger.LogError(ex, "An error occurred while running an event job.");
}
}
}

View File

@@ -0,0 +1,12 @@
using Quartz;
namespace FictionArchive.Service.SchedulerService.Models;
public class SchedulerJob
{
public JobKey JobKey { get; set; }
public string Description { get; set; }
public string JobTypeName { get; set; }
public List<string> CronSchedule { get; set; }
public Dictionary<string, string> JobData { get; set; }
}

View File

@@ -0,0 +1,74 @@
using FictionArchive.Service.SchedulerService.GraphQL;
using FictionArchive.Service.SchedulerService.Services;
using FictionArchive.Service.Shared.Extensions;
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
using Quartz;
using Quartz.Impl.AdoJobStore;
namespace FictionArchive.Service.SchedulerService;
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
// Services
builder.Services.AddDefaultGraphQl<Query, Mutation>();
builder.Services.AddHealthChecks();
builder.Services.AddTransient<JobManagerService>();
#region Database
builder.Services.RegisterDbContext<SchedulerServiceDbContext>(builder.Configuration.GetConnectionString("DefaultConnection"));
#endregion
#region Event Bus
builder.Services.AddRabbitMQ(opt =>
{
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
});
#endregion
#region Quartz
builder.Services.AddQuartz(opt =>
{
opt.UsePersistentStore(pso =>
{
pso.UsePostgres(pgsql =>
{
pgsql.ConnectionString = builder.Configuration.GetConnectionString("DefaultConnection");
pgsql.UseDriverDelegate<PostgreSQLDelegate>();
pgsql.TablePrefix = "quartz.qrtz_"; // Needed for Postgres due to the differing schema used
});
pso.UseNewtonsoftJsonSerializer();
});
});
builder.Services.AddQuartzHostedService(opt =>
{
opt.WaitForJobsToComplete = true;
});
#endregion
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<SchedulerServiceDbContext>();
dbContext.UpdateDatabase();
}
app.UseHttpsRedirection();
app.MapHealthChecks("/healthz");
app.MapGraphQL();
app.RunWithGraphQLCommands(args);
}
}

View File

@@ -0,0 +1,39 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:61312",
"sslPort": 44365
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5213",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "graphql",
"applicationUrl": "https://localhost:7145;http://localhost:5213",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,99 @@
using System.Data;
using FictionArchive.Service.SchedulerService.Models;
using FictionArchive.Service.SchedulerService.Models.JobTemplates;
using FictionArchive.Service.Shared.Services.EventBus;
using Quartz;
using Quartz.Impl.Matchers;
namespace FictionArchive.Service.SchedulerService.Services;
public class JobManagerService
{
private readonly ILogger<JobManagerService> _logger;
private readonly ISchedulerFactory _schedulerFactory;
public JobManagerService(ILogger<JobManagerService> logger, ISchedulerFactory schedulerFactory)
{
_logger = logger;
_schedulerFactory = schedulerFactory;
}
public async Task<List<SchedulerJob>> GetScheduledJobs()
{
var scheduler = await _schedulerFactory.GetScheduler();
var groups = await scheduler.GetJobGroupNames();
var result = new List<(IJobDetail Job, IReadOnlyCollection<ITrigger> Triggers)>();
foreach (var group in groups)
{
var jobKeys = await scheduler.GetJobKeys(GroupMatcher<JobKey>.GroupEquals(group));
foreach (var jobKey in jobKeys)
{
var jobDetail = await scheduler.GetJobDetail(jobKey);
var triggers = await scheduler.GetTriggersOfJob(jobKey);
result.Add((jobDetail, triggers));
}
}
return result.Select(tuple => new SchedulerJob()
{
JobKey = tuple.Job.Key,
Description = tuple.Job.Description,
JobTypeName = tuple.Job.JobType.FullName,
JobData = tuple.Job.JobDataMap.ToDictionary(kv => kv.Key, kv => kv.Value.ToString()),
CronSchedule = tuple.Triggers.Where(trigger => trigger is ICronTrigger).Select(trigger => (trigger as ICronTrigger).CronExpressionString).ToList()
}).ToList();
}
public async Task<SchedulerJob> ScheduleEventJob(string? jobKey, string? description, string eventType, string eventData, string cronSchedule)
{
var scheduler = await _schedulerFactory.GetScheduler();
if (await scheduler.GetJobDetail(new JobKey(jobKey)) != null)
{
throw new DuplicateNameException("A job with the same key already exists.");
}
jobKey ??= Guid.NewGuid().ToString();
var jobData = new JobDataMap
{
{ EventJobTemplate.EventTypeParameter, eventType },
{ EventJobTemplate.EventDataParameter, eventData }
};
var job = JobBuilder.Create<EventJobTemplate>()
.WithIdentity(jobKey)
.WithDescription(description ?? $"Fires off an event on a set schedule")
.SetJobData(jobData)
.Build();
var trigger = TriggerBuilder.Create()
.WithIdentity(jobKey)
.WithCronSchedule(cronSchedule)
.StartNow()
.Build();
await scheduler.ScheduleJob(job, trigger);
return new SchedulerJob()
{
CronSchedule = new List<string> { cronSchedule },
Description = description,
JobKey = new JobKey(jobKey),
JobTypeName = typeof(EventJobTemplate).FullName,
JobData = jobData.ToDictionary(kv => kv.Key, kv => kv.Value.ToString())
};
}
public async Task<bool> TriggerJob(string jobKey)
{
var scheduler = await _schedulerFactory.GetScheduler();
await scheduler.TriggerJob(new JobKey(jobKey));
return true;
}
public async Task<bool> DeleteJob(string jobKey)
{
var scheduler = await _schedulerFactory.GetScheduler();
return await scheduler.DeleteJob(new JobKey(jobKey));
}
}

View File

@@ -0,0 +1,19 @@
using AppAny.Quartz.EntityFrameworkCore.Migrations;
using AppAny.Quartz.EntityFrameworkCore.Migrations.PostgreSQL;
using FictionArchive.Service.Shared.Services.Database;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.SchedulerService.Services;
public class SchedulerServiceDbContext : FictionArchiveDbContext
{
public SchedulerServiceDbContext(DbContextOptions options, ILogger<SchedulerServiceDbContext> logger) : base(options, logger)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.AddQuartz(builder => builder.UsePostgreSql());
base.OnModelCreating(modelBuilder);
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,16 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"RabbitMQ": {
"ConnectionString": "amqp://localhost",
"ClientIdentifier": "SchedulerService"
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=FictionArchive_SchedulerService;Username=postgres;password=postgres"
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,6 @@
{
"subgraph": "Scheduler",
"http": {
"baseAddress": "http://localhost:5213/graphql"
}
}

View File

@@ -10,14 +10,21 @@ public static class GraphQLExtensions
public static IRequestExecutorBuilder AddDefaultGraphQl<TQuery, TMutation>(this IServiceCollection services) where TQuery : class where TMutation : class
{
return services.AddGraphQLServer()
.AddQueryType<TQuery>()
.AddMutationType<TMutation>()
.AddDiagnosticEventListener<ErrorEventListener>()
.AddType<UnsignedIntType>()
.AddType<InstantType>()
.AddMutationConventions(applyToAllMutations: true)
.AddFiltering(opt => opt.AddDefaults().BindRuntimeType<uint, UnsignedIntOperationFilterInputType>())
.AddSorting()
.AddProjections();
.AddQueryType<TQuery>()
.AddMutationType<TMutation>()
.ApplySaneDefaults();
}
public static IRequestExecutorBuilder ApplySaneDefaults(this IRequestExecutorBuilder builder)
{
return builder.AddDiagnosticEventListener<ErrorEventListener>()
.AddErrorFilter<LoggingErrorFilter>()
.AddType<UnsignedIntType>()
.AddType<InstantType>()
.AddMutationConventions(applyToAllMutations: true)
.AddFiltering(opt => opt.AddDefaults().BindRuntimeType<uint, UnsignedIntOperationFilterInputType>())
.AddSorting()
.AddProjections();
}
}

View File

@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="GraphQL.Server.Ui.GraphiQL" Version="8.3.3" />
<PackageReference Include="HotChocolate.AspNetCore" Version="15.1.11" />
<PackageReference Include="HotChocolate.AspNetCore.CommandLine" Version="15.1.11" />
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="15.1.11" />
<PackageReference Include="HotChocolate.Types.Scalars" Version="15.1.11" />
<PackageReference Include="HotChocolate.Types.NodaTime" Version="15.1.11" />

View File

@@ -16,7 +16,7 @@ public class EventBusBuilder<TEventBus> where TEventBus : class, IEventBus
_services.AddSingleton<SubscriptionManager>(_subscriptionManager);
}
public EventBusBuilder<TEventBus> Subscribe<TEvent, TEventHandler>() where TEvent : IntegrationEvent where TEventHandler : class, IIntegrationEventHandler<TEvent>
public EventBusBuilder<TEventBus> Subscribe<TEvent, TEventHandler>() where TEvent : IIntegrationEvent where TEventHandler : class, IIntegrationEventHandler<TEvent>
{
_services.AddKeyedTransient<IIntegrationEventHandler, TEventHandler>(typeof(TEvent).Name);
_subscriptionManager.RegisterSubscription<TEvent>();

View File

@@ -2,5 +2,6 @@ namespace FictionArchive.Service.Shared.Services.EventBus;
public interface IEventBus
{
Task Publish<TEvent>(TEvent integrationEvent) where TEvent : IntegrationEvent;
Task Publish<TEvent>(TEvent integrationEvent) where TEvent : IIntegrationEvent;
Task Publish(object integrationEvent, string eventType);
}

View File

@@ -0,0 +1,7 @@
using NodaTime;
namespace FictionArchive.Service.Shared.Services.EventBus;
public interface IIntegrationEvent
{
}

View File

@@ -1,12 +1,12 @@
namespace FictionArchive.Service.Shared.Services.EventBus;
public interface IIntegrationEventHandler<in TEvent> : IIntegrationEventHandler where TEvent : IntegrationEvent
public interface IIntegrationEventHandler<in TEvent> : IIntegrationEventHandler where TEvent : IIntegrationEvent
{
Task Handle(TEvent @event);
Task IIntegrationEventHandler.Handle(IntegrationEvent @event) => Handle((TEvent)@event);
Task IIntegrationEventHandler.Handle(IIntegrationEvent @event) => Handle((TEvent)@event);
}
public interface IIntegrationEventHandler
{
Task Handle(IntegrationEvent @event);
Task Handle(IIntegrationEvent @event);
}

View File

@@ -22,6 +22,8 @@ public class RabbitMQEventBus : IEventBus, IHostedService
private readonly JsonSerializerSettings _jsonSerializerSettings;
private const string ExchangeName = "fiction-archive-event-bus";
private const string CreatedAtHeader = "X-Created-At";
private const string EventIdHeader = "X-Event-Id";
public RabbitMQEventBus(IServiceScopeFactory serviceScopeFactory, RabbitMQConnectionProvider connectionProvider, IOptions<RabbitMQOptions> options, SubscriptionManager subscriptionManager, ILogger<RabbitMQEventBus> logger)
{
@@ -33,18 +35,28 @@ public class RabbitMQEventBus : IEventBus, IHostedService
_jsonSerializerSettings = new JsonSerializerSettings().ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
}
public async Task Publish<TEvent>(TEvent integrationEvent) where TEvent : IntegrationEvent
public async Task Publish<TEvent>(TEvent integrationEvent) where TEvent : IIntegrationEvent
{
var routingKey = typeof(TEvent).Name;
await Publish(integrationEvent, routingKey);
}
public async Task Publish(object integrationEvent, string eventType)
{
var channel = await _connectionProvider.GetDefaultChannelAsync();
// Set integration event values
integrationEvent.CreatedAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
integrationEvent.EventId = Guid.NewGuid();
var body = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(integrationEvent));
await channel.BasicPublishAsync(ExchangeName, routingKey, true, body);
_logger.LogInformation("Published event {EventName}", routingKey);
// headers
var props = new BasicProperties();
props.Headers = new Dictionary<string, object?>()
{
{ CreatedAtHeader, Instant.FromDateTimeUtc(DateTime.UtcNow).ToString() },
{ EventIdHeader, Guid.NewGuid().ToString() }
};
await channel.BasicPublishAsync(ExchangeName, eventType, true, props, body);
_logger.LogInformation("Published event {EventName}", eventType);
}
public async Task StartAsync(CancellationToken cancellationToken)
@@ -65,14 +77,14 @@ public class RabbitMQEventBus : IEventBus, IHostedService
return OnReceivedEvent(sender, @event, channel);
};
await channel.BasicConsumeAsync(_options.ClientIdentifier, false, consumer, cancellationToken: cancellationToken);
foreach (var subscription in _subscriptionManager.Subscriptions)
{
await channel.QueueBindAsync(_options.ClientIdentifier, ExchangeName, subscription.Key,
cancellationToken: cancellationToken);
_logger.LogInformation("Subscribed to {SubscriptionKey}", subscription.Key);
}
await channel.BasicConsumeAsync(_options.ClientIdentifier, false, consumer, cancellationToken: cancellationToken);
_logger.LogInformation("RabbitMQ EventBus started.");
}
@@ -101,7 +113,7 @@ public class RabbitMQEventBus : IEventBus, IHostedService
}
var eventBody = Encoding.UTF8.GetString(@event.Body.Span);
var eventObject = JsonConvert.DeserializeObject(eventBody, _subscriptionManager.Subscriptions[eventName], _jsonSerializerSettings) as IntegrationEvent;
var eventObject = JsonConvert.DeserializeObject(eventBody, _subscriptionManager.Subscriptions[eventName], _jsonSerializerSettings) as IIntegrationEvent;
using var scope = _serviceScopeFactory.CreateScope();

View File

@@ -1,9 +0,0 @@
using NodaTime;
namespace FictionArchive.Service.Shared.Services.EventBus;
public abstract class IntegrationEvent
{
public Guid EventId { get; set; }
public Instant CreatedAt { get; set; }
}

View File

@@ -0,0 +1,23 @@
using Microsoft.Extensions.Logging;
namespace FictionArchive.Service.Shared.Services.GraphQL;
public class LoggingErrorFilter : IErrorFilter
{
private readonly ILogger<LoggingErrorFilter> _logger;
public LoggingErrorFilter(ILogger<LoggingErrorFilter> logger)
{
_logger = logger;
}
public IError OnError(IError error)
{
if (error.Exception != null)
{
_logger.LogError(error.Exception, "Unexpected GraphQL error occurred");
}
return error;
}
}

View File

@@ -1,4 +1,5 @@
// <auto-generated />
using System;
using FictionArchive.Service.TranslationService.Services.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -12,7 +13,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace FictionArchive.Service.TranslationService.Migrations
{
[DbContext(typeof(TranslationServiceDbContext))]
[Migration("20251118052322_Initial")]
[Migration("20251122225458_Initial")]
partial class Initial
{
/// <inheritdoc />
@@ -27,11 +28,9 @@ namespace FictionArchive.Service.TranslationService.Migrations
modelBuilder.Entity("FictionArchive.Service.TranslationService.Models.Database.TranslationRequest", b =>
{
b.Property<long>("Id")
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
.HasColumnType("uuid");
b.Property<long>("BilledCharacterCount")
.HasColumnType("bigint");

View File

@@ -1,6 +1,6 @@
using Microsoft.EntityFrameworkCore.Migrations;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
@@ -16,8 +16,7 @@ namespace FictionArchive.Service.TranslationService.Migrations
name: "TranslationRequests",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Id = table.Column<Guid>(type: "uuid", nullable: false),
OriginalText = table.Column<string>(type: "text", nullable: false),
TranslatedText = table.Column<string>(type: "text", nullable: true),
From = table.Column<int>(type: "integer", nullable: false),

View File

@@ -1,4 +1,5 @@
// <auto-generated />
using System;
using FictionArchive.Service.TranslationService.Services.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -24,11 +25,9 @@ namespace FictionArchive.Service.TranslationService.Migrations
modelBuilder.Entity("FictionArchive.Service.TranslationService.Models.Database.TranslationRequest", b =>
{
b.Property<long>("Id")
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
.HasColumnType("uuid");
b.Property<long>("BilledCharacterCount")
.HasColumnType("bigint");

View File

@@ -4,7 +4,7 @@ using FictionArchive.Service.TranslationService.Models.Enums;
namespace FictionArchive.Service.TranslationService.Models.IntegrationEvents;
public class TranslationRequestCompletedEvent : IntegrationEvent
public class TranslationRequestCompletedEvent : IIntegrationEvent
{
/// <summary>
/// Maps this event back to a triggering request.

View File

@@ -3,7 +3,7 @@ using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.TranslationService.Models.IntegrationEvents;
public class TranslationRequestCreatedEvent : IntegrationEvent
public class TranslationRequestCreatedEvent : IIntegrationEvent
{
public Guid TranslationRequestId { get; set; }
public Language From { get; set; }

View File

@@ -73,6 +73,6 @@ public class Program
app.MapGraphQL();
app.Run();
app.RunWithGraphQLCommands(args);
}
}

Some files were not shown because too many files have changed in this diff Show More