Compare commits
70 Commits
3000942f1f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c40653b2b7 | |||
| f240d32ce6 | |||
| 4775e35b3c | |||
| a7535d460d | |||
| db344eebac | |||
| 9699c4d55c | |||
| c183626a7a | |||
| 5db182dda9 | |||
| 6296a91c3d | |||
| 91b7f96fdb | |||
| 88c4c23ce1 | |||
| a96b3c897c | |||
| a437bfcfc3 | |||
| b7b282a71a | |||
| 6083797eb1 | |||
| ecbd3daa1b | |||
| b66c10b681 | |||
| c49f03ec06 | |||
| 23ae78f6e1 | |||
| 0d4188b64e | |||
| 78a7517de7 | |||
| 244be555f9 | |||
| 6e658b8215 | |||
| f6c351c31e | |||
| 5baacbceea | |||
| feef8cbafd | |||
| b7379cf823 | |||
| 664eb07201 | |||
| 58c1301054 | |||
| 552f4f1f21 | |||
| 8f4b95f311 | |||
| 4fba72e99c | |||
| 55d8acef4c | |||
| 7d3f495472 | |||
| 802668fb0b | |||
| cd6f402d9e | |||
| 9bdef978bd | |||
| 2d472892d6 | |||
| 884ca4b96d | |||
| df0409d7f6 | |||
| 5077f557f4 | |||
| 1722d65d22 | |||
| 14023e65d5 | |||
| 237b1a4242 | |||
| ace0279bd0 | |||
| 07458c1541 | |||
| a9bfdc460d | |||
| 258554f9d4 | |||
| 6731fb5d3a | |||
| 5aaddbca40 | |||
| 1263e28c00 | |||
| 4873f39192 | |||
| cb6948aa14 | |||
| f9960b4fc9 | |||
| 2e4c16621d | |||
| 60ce08ee86 | |||
| 0f3652c1a1 | |||
| 63738ad027 | |||
| 6177eec2bf | |||
| b51b8b4185 | |||
| d222e33667 | |||
| fcd80cd30f | |||
| 43bcf449fd | |||
| 20f8a14bfb | |||
| 121757546a | |||
| b6eb692c27 | |||
| df3e602015 | |||
| 0f4acc1b6d | |||
| 3e28502750 | |||
| 659f82707a |
56
.gitea/workflows/deploy-socialize.yml
Normal file
56
.gitea/workflows/deploy-socialize.yml
Normal file
@@ -0,0 +1,56 @@
|
||||
name: deploy-socialize
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check repository hygiene
|
||||
run: ./scripts/check-repo-hygiene.sh
|
||||
- name: Install Docker CLI
|
||||
run: apt-get update && apt-get install -y docker.io
|
||||
- name: Login to Gitea container registry
|
||||
env:
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: printf '%s' "$REGISTRY_PASSWORD" | docker login git.mapachotes.com -u "$REGISTRY_USER" --password-stdin
|
||||
- name: Build images
|
||||
run: |
|
||||
docker build \
|
||||
-t git.mapachotes.com/jbourdon/socialize-api:${{ gitea.sha }} \
|
||||
-t git.mapachotes.com/jbourdon/socialize-api:latest \
|
||||
-f backend/src/Socialize.Api/Dockerfile .
|
||||
docker build \
|
||||
--build-arg VITE_API_URL=/api \
|
||||
-t git.mapachotes.com/jbourdon/socialize-web:${{ gitea.sha }} \
|
||||
-t git.mapachotes.com/jbourdon/socialize-web:latest \
|
||||
-f frontend/Dockerfile .
|
||||
- name: Push images
|
||||
run: |
|
||||
docker push git.mapachotes.com/jbourdon/socialize-api:${{ gitea.sha }}
|
||||
docker push git.mapachotes.com/jbourdon/socialize-api:latest
|
||||
docker push git.mapachotes.com/jbourdon/socialize-web:${{ gitea.sha }}
|
||||
docker push git.mapachotes.com/jbourdon/socialize-web:latest
|
||||
|
||||
deploy:
|
||||
needs: image
|
||||
runs-on: bookworm
|
||||
steps:
|
||||
- name: Install SSH client
|
||||
run: apt-get update && apt-get install -y openssh-client
|
||||
- name: Deploy on sobina
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_SSH_PRIVATE_KEY_B64: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY_B64 }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s' "$DEPLOY_SSH_PRIVATE_KEY_B64" | base64 -d > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
'cd /srv/prod/socialize && ./deploy.sh'
|
||||
39
.github/workflows/backend-ci.yml
vendored
39
.github/workflows/backend-ci.yml
vendored
@@ -1,39 +0,0 @@
|
||||
name: Backend CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
AZURE_WEBAPP_NAME: hutopy-backend-api
|
||||
DOTNET_VERSION: '9.0.x'
|
||||
|
||||
jobs:
|
||||
build_and_deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: dev
|
||||
steps:
|
||||
|
||||
# Checkout the repository
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
# Setup .NET Core
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v1
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
|
||||
# Run dotnet publish
|
||||
- name: dotnet build and publish
|
||||
run: |
|
||||
cd backend
|
||||
dotnet publish --configuration Release --artifacts-path ./publish/ backend.sln
|
||||
|
||||
# Deploy to Azure WebApp
|
||||
- name: Deploy to Azure WebApp
|
||||
uses: azure/webapps-deploy@v2
|
||||
with:
|
||||
app-name: ${{ env.AZURE_WEBAPP_NAME }}
|
||||
publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
|
||||
package: './backend/publish/publish/Hutopy/release/'
|
||||
38
.github/workflows/frontend-ci.yml
vendored
38
.github/workflows/frontend-ci.yml
vendored
@@ -1,38 +0,0 @@
|
||||
name: Frontend CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
AZURE_SWA_NAME: hutopy-portal
|
||||
|
||||
jobs:
|
||||
build_and_deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
# Checkout the repository
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
# Npm install
|
||||
- name: npm install
|
||||
run: |
|
||||
cd frontend
|
||||
npm install
|
||||
|
||||
# Npm run build
|
||||
- name: npm run build
|
||||
run: |
|
||||
cd frontend
|
||||
npm run build
|
||||
|
||||
# Deploy to Azure SWA
|
||||
- name: Deploy to Azure SWA
|
||||
uses: azure/static-web-apps-deploy@v1
|
||||
with:
|
||||
action: "upload"
|
||||
app_location: 'frontend'
|
||||
output_location: 'dist'
|
||||
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_TOKEN }}
|
||||
18
.gitignore
vendored
18
.gitignore
vendored
@@ -19,10 +19,28 @@
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# .NET
|
||||
bin/
|
||||
obj/
|
||||
**/[Bb]in/
|
||||
**/[Oo]bj/
|
||||
**/[Bb]in[\\]*
|
||||
**/[Oo]bj[\\]*
|
||||
TestResults/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
|
||||
# Local environment files
|
||||
*.local
|
||||
.env.local
|
||||
.env.*.local
|
||||
App_Data/
|
||||
|
||||
# Local SSL certificates
|
||||
*.pem
|
||||
|
||||
# Ai
|
||||
.codex
|
||||
|
||||
208
AGENTS.md
208
AGENTS.md
@@ -1,124 +1,124 @@
|
||||
# AGENTS.md
|
||||
# AGENTS
|
||||
|
||||
## Purpose
|
||||
This document is a working guide for coding agents in this repository. It captures the current architecture, conventions, and safe execution workflow for making reliable changes.
|
||||
This repository is designed for human + AI agent collaboration.
|
||||
|
||||
## Read Order
|
||||
|
||||
Before meaningful code changes, read:
|
||||
|
||||
1. `README.md`
|
||||
2. `docs/AGENTIC_WORKFLOW.md`
|
||||
3. `docs/ARCHITECTURE.md`
|
||||
4. `docs/DEVELOPMENT_WORKFLOW.md`
|
||||
5. `docs/PRODUCT.md`
|
||||
6. `docs/CONVENTIONS.md`
|
||||
7. Relevant file in `docs/FEATURES/`
|
||||
8. Relevant file in `docs/TASKS/`
|
||||
|
||||
## Core Rules
|
||||
|
||||
- Do not invent architecture.
|
||||
- Work from docs, feature specs, and task files instead of long chat history.
|
||||
- Keep backend code under `backend/src/Socialize.Api`.
|
||||
- The solution file is `backend/Socialize.slnx`.
|
||||
- Backend feature code currently follows FastEndpoints module folders under `Modules/<Feature>`.
|
||||
- Frontend feature-owned code belongs under `frontend/src/features/<feature>`.
|
||||
- Frontend runtime config must flow through `frontend/src/config.js`.
|
||||
- If backend contracts change, run `./scripts/update-openapi.sh` when the backend is running.
|
||||
- Dev servers use HTTP and bind to `0.0.0.0` for LAN access.
|
||||
- Avoid broad refactors unless the task explicitly asks for one.
|
||||
|
||||
## Repository Layout
|
||||
- `backend/`: ASP.NET Core (`net9.0`) API using FastEndpoints, EF Core (PostgreSQL), Stripe, Azure Blob Storage, and ASP.NET Identity.
|
||||
- `frontend/`: Vue 3 + Vite + Vuetify + Pinia + Vue Router + Tailwind CSS SPA.
|
||||
- `.github/workflows/`: deploy pipelines for backend (Azure Web App) and frontend (Azure Static Web Apps).
|
||||
|
||||
- `backend/src/Socialize.Api/`: ASP.NET Core `net10.0` API using FastEndpoints, EF Core, PostgreSQL, ASP.NET Identity, and workflow modules.
|
||||
- `backend/tests/Socialize.Tests/`: backend test project scaffold.
|
||||
- `frontend/`: Vue 3 + Vite + Vuetify + Pinia SPA.
|
||||
- `docs/FEATURES/`: product and technical feature specs.
|
||||
- `docs/TASKS/`: implementation tickets for coding agents.
|
||||
- `docs/PROMPTS/`: reusable agent prompt templates.
|
||||
- `docs/DECISIONS/`: architecture and product decision records.
|
||||
- `shared/openapi/`: backend OpenAPI schema snapshots.
|
||||
- `scripts/`: root developer workflow commands.
|
||||
- `deploy/caddy/`: Caddy reverse proxy config for Docker Compose.
|
||||
|
||||
## Local Runbook
|
||||
### Backend
|
||||
- Prereqs: .NET 9 SDK, Docker, PostgreSQL container.
|
||||
- Start database:
|
||||
- `cd backend`
|
||||
- `./scripts/start-infrastructure.sh`
|
||||
- Run API:
|
||||
- `dotnet run` (from `backend/`)
|
||||
- Swagger/OpenAPI UI in dev:
|
||||
- `/api`
|
||||
|
||||
### Frontend
|
||||
- Prereqs: Node/npm, local HTTPS cert files expected by Vite:
|
||||
- `frontend/localhost-key.pem`
|
||||
- `frontend/localhost.pem`
|
||||
- Commands:
|
||||
- `cd frontend && npm install`
|
||||
- `npm run dev`
|
||||
- `npm run build`
|
||||
Start infrastructure:
|
||||
|
||||
## Backend Architecture
|
||||
### Composition Root
|
||||
- Entry point: `backend/Program.cs`.
|
||||
- Registers:
|
||||
- Web services/auth (`backend/DependencyInjection.cs`)
|
||||
- Infrastructure services (`backend/Infrastructure/DependencyInjection.cs`)
|
||||
- Modules: Identity, Creators, Contents, Memberships, Tipping, Messaging.
|
||||
- Each module has:
|
||||
- `Add{Module}Module(...)` to register DbContext/services.
|
||||
- `Use{Module}ModuleAsync()` to auto-run migrations at startup.
|
||||
```bash
|
||||
./scripts/start-infrastructure.sh
|
||||
```
|
||||
|
||||
### API Style
|
||||
- FastEndpoints-based handlers.
|
||||
- Pattern: request/response records + optional FluentValidation validator + handler class.
|
||||
- Tagging via `Options(o => o.WithTags("..."))`.
|
||||
- File upload handlers call `AllowFileUploads()`.
|
||||
Run backend:
|
||||
|
||||
### Data Boundaries
|
||||
- Separate DbContext per module:
|
||||
- Identity, Creators, Contents, Memberships, Tipping, Messaging.
|
||||
- Migrations are module-scoped under each `Modules/*/Migrations` folder.
|
||||
```bash
|
||||
./scripts/dev-backend.sh
|
||||
```
|
||||
|
||||
### Auth/Security
|
||||
- JWT is generated manually in `Infrastructure/Security/GenerateJwtToken.cs`.
|
||||
- Refresh-token flow is implemented in Identity handlers (`/api/users/login`, `/api/users/refresh`).
|
||||
- User claim helpers live in `Infrastructure/Security/ClaimsPrincipalExtensions.cs`.
|
||||
Run frontend:
|
||||
|
||||
### Payments/Stripe
|
||||
- Tip checkout: `Infrastructure/Payments/Stripe/Services/StripeTipProcessor.cs`.
|
||||
- Membership checkout: `Infrastructure/Payments/Stripe/Services/MembershipPaymentProcessor.cs`.
|
||||
- Webhook endpoint: `Modules/Memberships/Handlers/StripeWebhookEndpoint.cs`.
|
||||
- Creator onboarding/status/revoke Stripe:
|
||||
- `/api/stripe/connect`
|
||||
- `/api/stripe/check-status`
|
||||
- `DELETE /api/stripe`
|
||||
```bash
|
||||
./scripts/dev-frontend.sh
|
||||
```
|
||||
|
||||
### Blob Storage
|
||||
- `IBlobStorage` implemented by `AzureBlobStorage`.
|
||||
- Upload size/type checks are enforced there (10 MB max + content-type validation).
|
||||
Update OpenAPI:
|
||||
|
||||
## Frontend Architecture
|
||||
### Bootstrap
|
||||
- `frontend/src/main.js` wires Vue app + Pinia + Vuetify + Router + i18n + Google OAuth + Toasts.
|
||||
```bash
|
||||
./scripts/update-openapi.sh
|
||||
```
|
||||
|
||||
### Routing
|
||||
- Defined in `frontend/src/router/router.js`.
|
||||
- Route guards enforce:
|
||||
- `meta.requiresAuth`
|
||||
- `meta.notAuthenticated`
|
||||
- Creator public route convention: `/@:creator`.
|
||||
## Current Domain Modules
|
||||
|
||||
### State Management
|
||||
- Pinia stores:
|
||||
- `authStore`: token lifecycle + refresh concurrency guard.
|
||||
- `userProfileStore`: current user profile and account edits.
|
||||
- `creatorProfileStore`: creator-owned profile actions.
|
||||
- `brandingStore`: creator page branding fetched from slug route param.
|
||||
- `Identity`: authentication, refresh tokens, email verification, password reset, social login.
|
||||
- `Organizations`: SaaS account ownership, billing/subscription boundary, organization membership, connectors, data mappings, and owned workspaces.
|
||||
- `Workspaces`: workspace membership, workspace settings, access scoping.
|
||||
- `Clients`: client records and primary contacts tied to workspaces.
|
||||
- `Projects`: project pipeline and client/project relationships.
|
||||
- `ContentItems`: reviewable content records tied to clients/projects.
|
||||
- `Assets`: linked asset metadata and revision history.
|
||||
- `Comments`: discussion threads on reviewable work.
|
||||
- `Approvals`: review decisions and workflow state transitions.
|
||||
- `Notifications`: activity feed and unread workflow notifications.
|
||||
- `Feedback`: product feedback reports, screenshots, comments, activity, and developer review workflows.
|
||||
|
||||
### API Client
|
||||
- Axios client in `frontend/src/plugins/api.js`.
|
||||
- Injects bearer token, proactively refreshes near expiry, retries once on 401.
|
||||
## Task Discipline
|
||||
|
||||
## High-Value Domains
|
||||
- Identity and social login (`Modules/Identity/*`, `frontend/src/views/auth/*`).
|
||||
- Creator public profile and management (`Modules/Creators/*`, `frontend/src/views/creators/*`, `frontend/src/views/profile/*`).
|
||||
- Monetization:
|
||||
- Tips (`Modules/Tipping/*`, creator donation UI)
|
||||
- Memberships (`Modules/Memberships/*`, Stripe webhook orchestration)
|
||||
- Content albums/photo upload (`Modules/Contents/*`).
|
||||
- Messaging thread/replies (`Modules/Messaging/*`).
|
||||
Agents should work from task files in `docs/TASKS/`.
|
||||
|
||||
## Agent Working Rules For This Repo
|
||||
1. Keep module boundaries intact. Do not couple DbContexts across modules.
|
||||
2. When adding endpoints, follow existing FastEndpoints pattern with validator + explicit route + tag.
|
||||
3. If schema changes are needed, generate migration in the matching module only.
|
||||
4. Preserve token refresh behavior in frontend client/store; avoid introducing parallel refresh races.
|
||||
5. For file uploads, enforce content-type/size limits and reuse blob path conventions.
|
||||
6. Keep creator route contract stable (`/@slug`) because frontend and backend both depend on it.
|
||||
7. Do not commit secrets. Existing appsettings include sensitive-looking values; treat as legacy and avoid propagating.
|
||||
A good task:
|
||||
|
||||
## Validation Checklist Before Finishing
|
||||
- Backend:
|
||||
- `cd backend && dotnet build`
|
||||
- run affected endpoint flows if change touches handlers/auth/payments/storage
|
||||
- Frontend:
|
||||
- `cd frontend && npm run build`
|
||||
- validate affected route/store interactions in browser
|
||||
- If migrations were changed:
|
||||
- ensure module context name/output directory remain consistent with `backend/scripts/add-migration.sh`.
|
||||
- has a clear goal
|
||||
- names the relevant feature spec
|
||||
- has a small scope
|
||||
- lists likely files
|
||||
- lists validation commands
|
||||
|
||||
## Notes / Known Sharp Edges
|
||||
- Frontend expects `VITE_API_URL` in API plugin; `src/config.js` uses `VITE_APP_API_URL` naming. Keep env usage consistent when editing.
|
||||
- `GetReceivedTips` currently resolves tipper with `tip.CreatorId` instead of `tip.CreatedBy`; verify intent before refactoring.
|
||||
- Some style/formatting is inconsistent across JS/Vue/C# files; minimize churn to touched lines.
|
||||
If no task exists, create one before implementing a meaningful feature.
|
||||
|
||||
## Validation
|
||||
|
||||
Backend:
|
||||
|
||||
```bash
|
||||
dotnet build backend/Socialize.slnx
|
||||
dotnet test backend/Socialize.slnx
|
||||
```
|
||||
|
||||
Frontend:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
Contract changes:
|
||||
|
||||
```bash
|
||||
./scripts/update-openapi.sh
|
||||
```
|
||||
|
||||
## Sharp Edges
|
||||
|
||||
- Existing checked-in env and appsettings files may include legacy sensitive-looking values; do not propagate those values into new docs or templates.
|
||||
- The frontend is still JavaScript, not the TypeScript starter app generated by the bootstrap script. New OpenAPI scaffolding exists, but migrating app code to generated typed API calls should happen by task.
|
||||
- Feature-owned frontend route views and stores now live under `frontend/src/features/*`; keep future feature work there.
|
||||
|
||||
173
README.md
173
README.md
@@ -1,110 +1,101 @@
|
||||
# Hutopy
|
||||
# Socialize
|
||||
|
||||
## Patterns / strategy used
|
||||
- Clean Architecture ( with Infrastructure, Domain, Application and Web layers )
|
||||
- Minimal API endpoints.
|
||||
Socialize is an organization-owned, workspace-based workflow application for social media content review, revision, approval, and publication readiness.
|
||||
|
||||
## Tools
|
||||
- Install Docker : https://www.docker.com/get-started/
|
||||
- Install sql server management ( or preferred tool ) : https://learn.microsoft.com/en-us/sql/ssms/download-sql-server-management-studio-ssms?view=sql-server-ver16#download-ssms
|
||||
It is not a public social network. The product is for internal teams, providers, and client approvers coordinating content work before publication.
|
||||
|
||||
## Database setup in docker for local dev
|
||||
```
|
||||
docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=P@ssword123!" -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest
|
||||
```
|
||||
## Monorepo
|
||||
|
||||
Or with a mounted volume to persist data on the computer instead ( persist data even if the container is deleted )
|
||||
```
|
||||
docker run -e 'ACCEPT_EULA=Y' -e 'MSSQL_SA_PASSWORD=P@ssword123!' -p 1433:1433 -v C:\dev\DockerVolumes\SqlServer-Utopy-1\data:/var/opt/mssql/data -v C:\dev\DockerVolumes\SqlServer-Utopy-1\log:/var/opt/mssql/log -v C:\dev\DockerVolumes\SqlServer-Utopy-1\secrets:/var/opt/mssql/secrets -d mcr.microsoft.com/mssql/server:2022-latest
|
||||
```
|
||||
- Backend: .NET 10 Web API in `backend/src/Socialize.Api`
|
||||
- Backend tests: `backend/tests/Socialize.Tests`
|
||||
- Frontend: Vue 3 + Vite + Vuetify + Pinia in `frontend`
|
||||
- API contract: OpenAPI snapshot in `shared/openapi`
|
||||
- Deployment: Docker Compose + Caddy
|
||||
- Agentic workflow: specs, task files, and prompt templates under `docs`
|
||||
|
||||
## Postgres DB setup in docker for local dev
|
||||
```
|
||||
docker run -p 5432:5432 --name Hutopy -e POSTGRES_PASSWORD=P@ssword123! -e POSTGRES_USER=sa -d postgres
|
||||
## Local Development
|
||||
|
||||
|
||||
```
|
||||
|
||||
## Entity Framework
|
||||
|
||||
Create a new migration :
|
||||
```
|
||||
./Ef.ps1 migrations add NomDeLaMigration
|
||||
```
|
||||
|
||||
Update database :
|
||||
```
|
||||
./Ef.ps1 database update
|
||||
```
|
||||
|
||||
## Secret Manager tool
|
||||
Go to Web project: cd src/Web
|
||||
|
||||
Add a user secret for local development :
|
||||
```
|
||||
dotnet user-secrets set "DB_PASSWORD" "12345"
|
||||
```
|
||||
|
||||
list your stored secrets :
|
||||
```
|
||||
dotnet user-secrets list
|
||||
```
|
||||
|
||||
Delete a secret :
|
||||
```
|
||||
dotnet user-secrets remove "DB_PASSWORD"
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
Run `dotnet build -tl` to build the solution.
|
||||
|
||||
## Run
|
||||
|
||||
To run the web application:
|
||||
Terminal 1:
|
||||
|
||||
```bash
|
||||
cd .\src\Web\
|
||||
dotnet watch run
|
||||
./scripts/start-infrastructure.sh
|
||||
./scripts/dev-backend.sh
|
||||
```
|
||||
|
||||
Navigate to https://localhost:5001. The application will automatically reload if you change any of the source files.
|
||||
|
||||
## Code Styles & Formatting
|
||||
|
||||
The template includes [EditorConfig](https://editorconfig.org/) support to help maintain consistent coding styles for multiple developers working on the same project across various editors and IDEs. The **.editorconfig** file defines the coding styles applicable to this solution.
|
||||
|
||||
## Code Scaffolding
|
||||
|
||||
Scaffold new commands and queries.
|
||||
|
||||
Start in the `.\src\Application\` folder.
|
||||
|
||||
Create a new command:
|
||||
|
||||
```
|
||||
dotnet new ca-usecase --name CreateTodoList --feature-name TodoLists --usecase-type command --return-type int
|
||||
```
|
||||
|
||||
Create a new query:
|
||||
|
||||
```
|
||||
dotnet new ca-usecase -n GetTodos -fn TodoLists -ut query -rt TodosVm
|
||||
```
|
||||
|
||||
If you encounter the error *"No templates or subcommands found matching: 'ca-usecase'."*, install the template and try again:
|
||||
Terminal 2:
|
||||
|
||||
```bash
|
||||
dotnet new install Clean.Architecture.Solution.Template::8.0.4
|
||||
./scripts/dev-frontend.sh
|
||||
```
|
||||
|
||||
## Test
|
||||
Frontend:
|
||||
|
||||
The solution contains unit, integration, and functional tests.
|
||||
```txt
|
||||
http://localhost:5173
|
||||
http://<this-machine-lan-ip>:5173
|
||||
```
|
||||
|
||||
- Using Moq, Nunit, Respawn, FluentAssertions
|
||||
Backend:
|
||||
|
||||
```txt
|
||||
http://localhost:5080
|
||||
http://<this-machine-lan-ip>:5080
|
||||
```
|
||||
|
||||
Swagger UI:
|
||||
|
||||
```txt
|
||||
http://localhost:5080/api
|
||||
```
|
||||
|
||||
## Update Frontend API Types
|
||||
|
||||
The backend must be running first.
|
||||
|
||||
To run the tests:
|
||||
```bash
|
||||
dotnet test
|
||||
./scripts/update-openapi.sh
|
||||
```
|
||||
|
||||
This writes:
|
||||
|
||||
```txt
|
||||
shared/openapi/openapi.json
|
||||
frontend/src/api/schema.d.ts
|
||||
```
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
```txt
|
||||
http://localhost:8080
|
||||
http://<this-machine-lan-ip>:8080
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```bash
|
||||
dotnet build backend/Socialize.slnx
|
||||
dotnet test backend/Socialize.slnx
|
||||
```
|
||||
|
||||
## Frontend Build
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Agentic Workflow
|
||||
|
||||
Start here:
|
||||
|
||||
```txt
|
||||
docs/AGENTIC_WORKFLOW.md
|
||||
```
|
||||
|
||||
Use feature specs, task files, and prompt templates instead of asking agents to work from vague chat history.
|
||||
|
||||
330
TEMPLATE_PROMPT.md
Normal file
330
TEMPLATE_PROMPT.md
Normal file
@@ -0,0 +1,330 @@
|
||||
# PROMPT TEMPLATES
|
||||
I need you to help me write a feature. First, we need to define it, so you will ask me questions one-by-one to make sure we have a shared understanding of the scope
|
||||
and expectating. - The feature we want is a way for your clients to report bugs/suggestions/requests from within our app. It should not be intrusive. It should allow
|
||||
them to take a screen capture, put annotation, describe their request and/or issue. Then, as a dev, i will want to collect and review them.
|
||||
|
||||
## Purpose
|
||||
This document standardizes how we interact with AI coding agents (Codex, Claude, etc).
|
||||
|
||||
Goals:
|
||||
- Reduce prompt variability
|
||||
- Prevent architectural drift
|
||||
- Improve consistency and reliability
|
||||
- Enable repeatable workflows
|
||||
|
||||
---
|
||||
|
||||
# 🧠 Core Principle
|
||||
|
||||
The AI is NOT the source of truth.
|
||||
|
||||
The source of truth is:
|
||||
- docs/PRODUCT.md
|
||||
- docs/ARCHITECTURE.md
|
||||
- docs/CONVENTIONS.md
|
||||
- docs/DECISIONS.md
|
||||
- docs/tasks/*.md
|
||||
|
||||
All prompts MUST reference these.
|
||||
|
||||
---
|
||||
|
||||
# 🔁 Standard Workflow
|
||||
|
||||
1. PLAN
|
||||
2. BREAKDOWN (optional)
|
||||
3. IMPLEMENT (step-by-step)
|
||||
4. REVIEW
|
||||
|
||||
Never skip directly to implementation for non-trivial features.
|
||||
|
||||
---
|
||||
|
||||
# 🧩 Prompt Templates
|
||||
|
||||
---
|
||||
|
||||
## 1. PLAN (default starting point)
|
||||
|
||||
### When to use
|
||||
- New feature
|
||||
- Complex change
|
||||
- Refactor
|
||||
- Anything unclear
|
||||
|
||||
### Prompt
|
||||
|
||||
You are working inside an existing repository.
|
||||
|
||||
Before doing anything:
|
||||
1. Read:
|
||||
- AGENTS.md
|
||||
- docs/PRODUCT.md
|
||||
- docs/ARCHITECTURE.md
|
||||
- docs/CONVENTIONS.md
|
||||
- docs/DECISIONS.md
|
||||
2. Read the task:
|
||||
- docs/tasks/TASK-XXX.md
|
||||
|
||||
Do NOT modify code.
|
||||
|
||||
Output:
|
||||
1. Summary (<=10 lines)
|
||||
2. Relevant architecture
|
||||
3. Files likely involved
|
||||
4. Implementation plan
|
||||
5. Risks / ambiguities
|
||||
|
||||
---
|
||||
|
||||
## 2. BREAKDOWN
|
||||
|
||||
### When to use
|
||||
- Task is too large
|
||||
- You want step-by-step execution
|
||||
|
||||
### Prompt
|
||||
|
||||
Break this task into atomic steps.
|
||||
|
||||
For each step:
|
||||
- goal
|
||||
- files involved
|
||||
- dependencies
|
||||
- risks
|
||||
|
||||
Constraints:
|
||||
- 3–7 steps max
|
||||
- each step must be independently implementable
|
||||
- keep changes small
|
||||
|
||||
---
|
||||
|
||||
## 3. IMPLEMENT
|
||||
|
||||
### When to use
|
||||
- After plan is validated
|
||||
|
||||
### Prompt
|
||||
|
||||
Implement ONLY the agreed plan.
|
||||
|
||||
Read first:
|
||||
- AGENTS.md
|
||||
- docs/*
|
||||
- docs/tasks/TASK-XXX.md
|
||||
|
||||
Rules:
|
||||
- Minimal diff
|
||||
- No refactor outside scope
|
||||
- No new libraries
|
||||
- Respect architecture and conventions
|
||||
|
||||
At the end:
|
||||
1. Modified files
|
||||
2. Summary of changes
|
||||
3. Commands to test
|
||||
4. Remaining risks
|
||||
|
||||
---
|
||||
|
||||
## 4. STEP IMPLEMENTATION
|
||||
|
||||
### When to use
|
||||
- When using breakdown approach
|
||||
|
||||
### Prompt
|
||||
|
||||
Implement ONLY step X.
|
||||
|
||||
Do not touch anything outside this step.
|
||||
|
||||
Stop after completion.
|
||||
|
||||
---
|
||||
|
||||
## 5. REVIEW
|
||||
|
||||
### When to use
|
||||
- Before commit
|
||||
- After major change
|
||||
|
||||
### Prompt
|
||||
|
||||
Review the implementation against:
|
||||
|
||||
- docs/tasks/TASK-XXX.md
|
||||
- docs/ARCHITECTURE.md
|
||||
- docs/CONVENTIONS.md
|
||||
|
||||
Check:
|
||||
- acceptance criteria
|
||||
- architecture violations
|
||||
- regression risks
|
||||
- missing edge cases
|
||||
|
||||
Output:
|
||||
1. Issues
|
||||
2. Fix suggestions
|
||||
3. Risk level
|
||||
4. Ready to commit? (yes/no)
|
||||
|
||||
---
|
||||
|
||||
## 6. ANALYSIS (no code)
|
||||
|
||||
### When to use
|
||||
- Debugging
|
||||
- Understanding codebase
|
||||
- Investigating issues
|
||||
|
||||
### Prompt
|
||||
|
||||
Do NOT modify code.
|
||||
|
||||
Analyze:
|
||||
- architecture consistency
|
||||
- state management
|
||||
- API usage
|
||||
- potential bugs
|
||||
|
||||
Output:
|
||||
- what is correct
|
||||
- what is fragile
|
||||
- what should be improved
|
||||
|
||||
---
|
||||
|
||||
## 7. TASK GENERATION
|
||||
|
||||
### When to use
|
||||
- Turning feature idea into executable task
|
||||
|
||||
### Prompt
|
||||
|
||||
Generate a TASK.md file.
|
||||
|
||||
Include:
|
||||
- Objective
|
||||
- Scope
|
||||
- Out of scope
|
||||
- Backend section
|
||||
- Frontend section
|
||||
- API contract
|
||||
- Files involved
|
||||
- Acceptance criteria
|
||||
- Edge cases
|
||||
- Constraints
|
||||
|
||||
Must be:
|
||||
- clear
|
||||
- complete
|
||||
- self-contained
|
||||
|
||||
---
|
||||
|
||||
## 8. STRICT MODE
|
||||
|
||||
### When to use
|
||||
- Agent starts drifting
|
||||
- Too many unexpected changes
|
||||
|
||||
### Prompt
|
||||
|
||||
STRICT MODE:
|
||||
|
||||
- No assumptions
|
||||
- No extra features
|
||||
- No refactoring
|
||||
- No architecture changes
|
||||
|
||||
Do ONLY what is defined.
|
||||
|
||||
If unclear → stop and ask.
|
||||
|
||||
---
|
||||
|
||||
## 9. ANTI-HALLUCINATION
|
||||
|
||||
### When to use
|
||||
- Missing info
|
||||
- Unclear requirements
|
||||
|
||||
### Prompt
|
||||
|
||||
If information is missing:
|
||||
- do NOT assume
|
||||
- do NOT invent
|
||||
|
||||
Instead:
|
||||
- list missing info
|
||||
- propose options
|
||||
|
||||
Wait for clarification.
|
||||
|
||||
---
|
||||
|
||||
## 10. STACK-SPECIFIC (Vue + .NET)
|
||||
|
||||
### When to use
|
||||
- Reinforce stack constraints
|
||||
|
||||
### Prompt
|
||||
|
||||
You are working on:
|
||||
|
||||
Frontend:
|
||||
- Vue 3
|
||||
- Pinia
|
||||
- Tailwind
|
||||
|
||||
Backend:
|
||||
- .NET FastEndpoints
|
||||
- Modular DbContexts
|
||||
|
||||
Rules:
|
||||
|
||||
Backend:
|
||||
- follow FastEndpoints pattern
|
||||
- no cross-module DbContext coupling
|
||||
|
||||
Frontend:
|
||||
- use Pinia for state
|
||||
- no business logic in components
|
||||
- use API client
|
||||
|
||||
Always align with docs.
|
||||
|
||||
---
|
||||
|
||||
# 🚨 Rules
|
||||
|
||||
- Never start coding without reading docs
|
||||
- Never trust conversation history alone
|
||||
- Always constrain scope
|
||||
- Always review before commit
|
||||
|
||||
---
|
||||
|
||||
# 🧭 Summary
|
||||
|
||||
Bad:
|
||||
"Add profile feature"
|
||||
|
||||
Good:
|
||||
- PLAN
|
||||
- IMPLEMENT step 1
|
||||
- REVIEW
|
||||
- repeat
|
||||
|
||||
---
|
||||
|
||||
# 🔥 Recommended Usage with CLI
|
||||
|
||||
scripts/ai-task plan docs/tasks/TASK-XXX.md
|
||||
scripts/ai-task implement docs/tasks/TASK-XXX.md
|
||||
scripts/ai-task review docs/tasks/TASK-XXX.md
|
||||
|
||||
---
|
||||
|
||||
End of document.
|
||||
82
backend/.github/workflows/build.yml
vendored
82
backend/.github/workflows/build.yml
vendored
@@ -1,82 +0,0 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- '.scripts/**'
|
||||
- .gitignore
|
||||
- CODE_OF_CONDUCT.md
|
||||
- LICENSE
|
||||
- README.md
|
||||
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-artifacts:
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
name: Checkout code
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nuget-
|
||||
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@v3
|
||||
|
||||
- name: Restore solution
|
||||
run: dotnet restore
|
||||
|
||||
- name: Build solution
|
||||
run: dotnet build --no-restore --configuration Release
|
||||
|
||||
- name: Test solution
|
||||
run: dotnet test --no-build --configuration Release --filter "FullyQualifiedName!~AcceptanceTests"
|
||||
|
||||
- name: Publish website
|
||||
if: ${{ inputs.build-artifacts == true }}
|
||||
run: |
|
||||
dotnet publish --configuration Release --runtime win-x86 --self-contained --output ./publish
|
||||
cd publish
|
||||
zip -r ./publish.zip .
|
||||
working-directory: ./src/Web/
|
||||
|
||||
- name: Upload website artifact (website)
|
||||
if: ${{ inputs.build-artifacts == true }}
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: website
|
||||
path: ./src/Web/publish/publish.zip
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create EF Core migrations bundle
|
||||
if: ${{ inputs.build-artifacts == true }}
|
||||
run: |
|
||||
dotnet new tool-manifest
|
||||
dotnet tool install dotnet-ef
|
||||
dotnet ef migrations bundle --configuration Release -p ./src/Infrastructure/ -s ./src/Web/ -o efbundle.exe
|
||||
zip -r ./efbundle.zip efbundle.exe
|
||||
env:
|
||||
SkipNSwag: True
|
||||
|
||||
- name: Upload EF Core migrations bundle artifact (efbundle)
|
||||
if: ${{ inputs.build-artifacts == true }}
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: efbundle
|
||||
path: ./efbundle.zip
|
||||
if-no-files-found: error
|
||||
42
backend/.github/workflows/cicd.yml
vendored
42
backend/.github/workflows/cicd.yml
vendored
@@ -1,42 +0,0 @@
|
||||
name: CICD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- .gitignore
|
||||
- CODE_OF_CONDUCT.md
|
||||
- LICENSE
|
||||
- README.md
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
uses: ./.github/workflows/build.yml
|
||||
with:
|
||||
build-artifacts: true
|
||||
|
||||
deploy-development:
|
||||
uses: ./.github/workflows/deploy.yml
|
||||
secrets: inherit
|
||||
needs: [ build ]
|
||||
with:
|
||||
environmentName: Development
|
||||
|
||||
deploy-staging:
|
||||
uses: ./.github/workflows/deploy.yml
|
||||
secrets: inherit
|
||||
needs: [ deploy-development ]
|
||||
with:
|
||||
environmentName: Staging
|
||||
|
||||
deploy-production:
|
||||
uses: ./.github/workflows/deploy.yml
|
||||
secrets: inherit
|
||||
needs: [ deploy-staging ]
|
||||
with:
|
||||
environmentName: Production
|
||||
107
backend/.github/workflows/deploy.yml
vendored
107
backend/.github/workflows/deploy.yml
vendored
@@ -1,107 +0,0 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
environmentName:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ inputs.environmentName }}
|
||||
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
name: Checkout code
|
||||
|
||||
- uses: azure/login@v1
|
||||
name: Login to Azure
|
||||
with:
|
||||
client-id: ${{ vars.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ vars.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- if: inputs.environmentName == 'Development'
|
||||
uses: azure/arm-deploy@v1
|
||||
name: Run preflight validation
|
||||
with:
|
||||
deploymentName: ${{ github.run_number }}
|
||||
resourceGroupName: ${{ vars.AZURE_RESOURCE_GROUP_NAME }}
|
||||
template: ./.azure/bicep/main.bicep
|
||||
parameters: >
|
||||
environmentName=${{ inputs.environmentName }}
|
||||
sqlAdministratorUsername=${{ vars.AZURE_SQL_ADMINISTRATOR_USERNAME }}
|
||||
sqlAdministratorPassword=${{ secrets.AZURE_SQL_ADMINISTRATOR_PASSWORD }}
|
||||
projectName=${{ vars.PROJECT_NAME }}
|
||||
deploymentMode: Validate
|
||||
|
||||
- if: inputs.environmentName != 'Development'
|
||||
uses: azure/arm-deploy@v1
|
||||
name: Run what-if
|
||||
with:
|
||||
failOnStdErr: false
|
||||
resourceGroupName: ${{ vars.AZURE_RESOURCE_GROUP_NAME }}
|
||||
template: ./.azure/bicep/main.bicep
|
||||
parameters: >
|
||||
environmentName=${{ inputs.environmentName }}
|
||||
sqlAdministratorUsername=${{ vars.AZURE_SQL_ADMINISTRATOR_USERNAME }}
|
||||
sqlAdministratorPassword=${{ secrets.AZURE_SQL_ADMINISTRATOR_PASSWORD }}
|
||||
projectName=${{ vars.PROJECT_NAME }}
|
||||
additionalArguments: --what-if
|
||||
|
||||
deploy:
|
||||
needs: [ validate ]
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ inputs.environmentName }}
|
||||
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
name: Checkout code
|
||||
|
||||
- uses: actions/download-artifact@v3
|
||||
name: Download artifacts
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@v3
|
||||
|
||||
- uses: azure/login@v1
|
||||
name: Login to Azure
|
||||
with:
|
||||
client-id: ${{ vars.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ vars.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- uses: azure/arm-deploy@v1
|
||||
id: deploy
|
||||
name: Deploy infrastructure
|
||||
with:
|
||||
failOnStdErr: false
|
||||
deploymentName: ${{ github.run_number }}
|
||||
resourceGroupName: ${{ vars.AZURE_RESOURCE_GROUP_NAME }}
|
||||
template: ./.azure/bicep/main.bicep
|
||||
parameters: >
|
||||
environmentName=${{ inputs.environmentName }}
|
||||
sqlAdministratorUsername=${{ vars.AZURE_SQL_ADMINISTRATOR_USERNAME }}
|
||||
sqlAdministratorPassword=${{ secrets.AZURE_SQL_ADMINISTRATOR_PASSWORD }}
|
||||
projectName=${{ vars.PROJECT_NAME }}
|
||||
|
||||
- name: Initialise database
|
||||
run: |
|
||||
unzip -o ./efbundle/efbundle.zip
|
||||
echo '{ "ConnectionStrings": { "DefaultConnection": "" } }' > appsettings.json
|
||||
./efbundle.exe --connection "Server=${{ steps.deploy.outputs.sqlServerFullyQualifiedDomainName }};Initial Catalog=${{ steps.deploy.outputs.sqlDatabaseName }};Persist Security Info=False;User ID=${{ vars.AZURE_SQL_ADMINISTRATOR_USERNAME }};Password=${{ secrets.AZURE_SQL_ADMINISTRATOR_PASSWORD }};MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" --verbose
|
||||
|
||||
- uses: azure/webapps-deploy@v2
|
||||
name: Deploy website
|
||||
with:
|
||||
app-name: ${{ steps.deploy.outputs.appServiceAppName }}
|
||||
package: website/publish.zip
|
||||
@@ -1,4 +0,0 @@
|
||||
global using FastEndpoints;
|
||||
global using FluentValidation;
|
||||
global using JetBrains.Annotations;
|
||||
global using Microsoft.EntityFrameworkCore;
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace Hutopy.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
internal static class ContainerNames
|
||||
{
|
||||
public const string Users = "users";
|
||||
public const string Creators = "creators";
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
using Azure;
|
||||
using Azure.Storage.Blobs;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
using Hutopy.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
namespace Hutopy.Infrastructure.BlobStorage.Services;
|
||||
|
||||
public class AzureBlobStorage : IBlobStorage
|
||||
{
|
||||
private const long MaxUploadSize = 10 * 1024 * 1024; // 10 MB in bytes
|
||||
|
||||
private readonly BlobServiceClient _blobServiceClient;
|
||||
private readonly ILogger<AzureBlobStorage> _logger;
|
||||
|
||||
public AzureBlobStorage(IConfiguration configuration, ILogger<AzureBlobStorage> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
string? connectionString = configuration.GetConnectionString("AzureBlob");
|
||||
_blobServiceClient = new BlobServiceClient(connectionString);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upload a file to microsoft azure blob storage.
|
||||
/// </summary>
|
||||
/// <param name="containerName">The name of the container where the file is stored.</param>
|
||||
/// <param name="blobName">The blob name (path within the container, include the file name).</param>
|
||||
/// <param name="stream"></param>
|
||||
/// <param name="contentType">The content type.</param>
|
||||
/// <param name="ct">The cancellation token</param>
|
||||
/// <returns></returns>
|
||||
public async Task<string> UploadFileAsync(
|
||||
string containerName,
|
||||
string blobName,
|
||||
Stream stream,
|
||||
string contentType,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Read the file stream into a memory stream to determine the length
|
||||
// WATCH FOR MEMORY USAGE USING THE MEMORY STREAM.
|
||||
stream.Position = 0;
|
||||
|
||||
// Check if the file size exceeds the maximum upload size
|
||||
if (stream.Length > MaxUploadSize)
|
||||
{
|
||||
_logger.LogError(
|
||||
$"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
|
||||
throw new InvalidOperationException(
|
||||
$"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
|
||||
}
|
||||
|
||||
// Validate content type
|
||||
if (!ContentTypes.IsAllowed(contentType, stream))
|
||||
{
|
||||
_logger.LogError(
|
||||
$"Blob storage: Unsupported file type {contentType}.");
|
||||
throw new InvalidOperationException("Unsupported file type.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Get a reference to a container
|
||||
BlobContainerClient? containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
|
||||
|
||||
// Create the container if it does not exist
|
||||
await containerClient.CreateIfNotExistsAsync(
|
||||
PublicAccessType.Blob,
|
||||
cancellationToken: ct);
|
||||
|
||||
// Get a reference to a blob
|
||||
BlobClient? blobClient = containerClient.GetBlobClient(blobName);
|
||||
|
||||
// Define the BlobHttpHeaders to include the content type
|
||||
BlobHttpHeaders blobHttpHeaders = new() { ContentType = contentType };
|
||||
|
||||
// Upload the file
|
||||
Response<BlobContentInfo>? response = await blobClient.UploadAsync(
|
||||
stream,
|
||||
new BlobUploadOptions { HttpHeaders = blobHttpHeaders },
|
||||
ct);
|
||||
|
||||
string fileUri = blobClient.Uri.ToString();
|
||||
|
||||
_logger.LogInformation(
|
||||
"""
|
||||
Blob storage: Status [ {ResponseStatus} ]
|
||||
Uploaded [ {BlobName} ] to the container [ {ContainerName} ]
|
||||
with contentType [ {ContentType} ]
|
||||
with a length of [ {StreamLength} bytes ]
|
||||
with the uri [ {FileUri} ]
|
||||
""",
|
||||
response.GetRawResponse().Status.ToString(),
|
||||
blobName,
|
||||
containerName,
|
||||
contentType,
|
||||
stream.Length,
|
||||
fileUri
|
||||
);
|
||||
|
||||
// Return the URI of the uploaded blob
|
||||
return fileUri;
|
||||
}
|
||||
catch (RequestFailedException ex)
|
||||
{
|
||||
_logger.LogError($"Blob storage: Azure Storage request failed: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError($"Blob storage: An error occurred: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download a file to microsoft's azure blob storage.
|
||||
/// </summary>
|
||||
/// <param name="blobName">The blob name (path within the container).</param>
|
||||
/// <param name="containerName">The name of the container where the file is stored. (users)</param>
|
||||
/// <param name="ct">The cancellation token for the request</param>
|
||||
/// <returns></returns>
|
||||
public async Task<MemoryStream> DownloadFileAsync(
|
||||
string containerName,
|
||||
string blobName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get a reference to a container
|
||||
BlobContainerClient? containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
|
||||
|
||||
// Get a reference to a blob
|
||||
BlobClient? blobClient = containerClient.GetBlobClient(blobName);
|
||||
|
||||
// Download the blob to a stream
|
||||
BlobDownloadInfo download = await blobClient.DownloadAsync(ct);
|
||||
|
||||
MemoryStream memoryStream = new();
|
||||
await download.Content.CopyToAsync(memoryStream, ct);
|
||||
memoryStream.Position = 0; // Ensure the stream is at the beginning
|
||||
|
||||
return memoryStream;
|
||||
}
|
||||
catch (RequestFailedException ex)
|
||||
{
|
||||
_logger.LogError($"Azure Storage request failed: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError($"An error occurred: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Hutopy.Infrastructure.Configuration;
|
||||
|
||||
public class WebsiteOptions
|
||||
{
|
||||
public const string SectionName = "Website";
|
||||
|
||||
public string FrontendBaseUrl { get; set; } = "https://localhost:5173";
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
using Hutopy.Infrastructure.BlobStorage.Contracts;
|
||||
using Hutopy.Infrastructure.BlobStorage.Services;
|
||||
using Hutopy.Infrastructure.Configuration;
|
||||
using Hutopy.Infrastructure.Emailer.Configuration;
|
||||
using Hutopy.Infrastructure.Emailer.Contracts;
|
||||
using Hutopy.Infrastructure.Emailer.Services;
|
||||
using Hutopy.Infrastructure.Payments.Stripe.Configuration;
|
||||
using Hutopy.Infrastructure.Payments.Stripe.Services;
|
||||
using Hutopy.Modules.Memberships.Contracts;
|
||||
using Hutopy.Modules.Tipping.Contracts;
|
||||
|
||||
namespace Hutopy.Infrastructure;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddInfrastructureModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.Configure<WebsiteOptions>(
|
||||
builder.Configuration.GetRequiredSection(WebsiteOptions.SectionName));
|
||||
|
||||
builder.Services.AddTransient<IBlobStorage, AzureBlobStorage>();
|
||||
|
||||
builder.Services.AddTransient<ITipProcessor, StripeTipProcessor>();
|
||||
builder.Services.AddTransient<IMembershipPaymentProcessor, MembershipPaymentProcessor>();
|
||||
builder.Services.AddTransient<IMembershipCancellationProcessor, MembershipCancellationProcessor>();
|
||||
builder.Services.AddTransient<IMembershipTierProcessor, MembershipTierProcessor>();
|
||||
builder.Services.Configure<StripeOptions>(
|
||||
builder.Configuration.GetSection(StripeOptions.ConfigurationSection));
|
||||
|
||||
builder.Services.Configure<EmailerOptions>(
|
||||
builder.Configuration.GetSection(EmailerOptions.ConfigurationSection));
|
||||
builder.Services.AddTransient<IEmailSender, ResendEmailSender>();
|
||||
//builder.Services.AddTransient<IEmailSender, EmailSender>();
|
||||
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using Hutopy.Modules.Memberships.Contracts;
|
||||
using Stripe;
|
||||
|
||||
namespace Hutopy.Infrastructure.Payments.Stripe.Services;
|
||||
|
||||
public sealed class MembershipCancellationProcessor
|
||||
: IMembershipCancellationProcessor
|
||||
{
|
||||
public async Task<DateTimeOffset?> CancelAsync(
|
||||
string subscriptionId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
SubscriptionService subscriptionService = new();
|
||||
|
||||
// Stripe - Cancel Subscription immediately
|
||||
// var subscription = await subscriptionService.CancelAsync(
|
||||
// subscriptionId,
|
||||
// cancellationToken: ct);
|
||||
|
||||
// Stripe - Cancel Subscription AtPeriodEnd
|
||||
Subscription? subscription = await subscriptionService.UpdateAsync(
|
||||
subscriptionId,
|
||||
new SubscriptionUpdateOptions { CancelAtPeriodEnd = true },
|
||||
cancellationToken: ct);
|
||||
|
||||
return subscription.CancelAt ?? subscription.CanceledAt;
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
using Hutopy.Infrastructure.Payments.Stripe.Configuration;
|
||||
using Hutopy.Modules.Creators.Contracts;
|
||||
using Hutopy.Modules.Memberships.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
|
||||
namespace Hutopy.Infrastructure.Payments.Stripe.Services;
|
||||
|
||||
public class MembershipPaymentProcessor(
|
||||
IOptions<StripeOptions> stripeOptions)
|
||||
: IMembershipPaymentProcessor
|
||||
{
|
||||
public async Task<MembershipCheckoutSession> CreateCheckoutSessionAsync(
|
||||
Guid userId,
|
||||
CreatorReference creatorReference,
|
||||
Guid tierId,
|
||||
string priceId,
|
||||
string successUrl,
|
||||
string cancelUrl)
|
||||
{
|
||||
StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey;
|
||||
|
||||
// Create Stripe customer for the user if not already created
|
||||
CustomerService customerService = new();
|
||||
Customer? customer = await customerService.CreateAsync(
|
||||
new CustomerCreateOptions
|
||||
{
|
||||
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } }
|
||||
});
|
||||
|
||||
// Create Checkout Session for the subscription
|
||||
SessionService sessionService = new();
|
||||
Session? session = await sessionService.CreateAsync(
|
||||
new SessionCreateOptions
|
||||
{
|
||||
Customer = customer.Id,
|
||||
PaymentMethodTypes = ["card"],
|
||||
LineItems =
|
||||
[
|
||||
new SessionLineItemOptions { Price = priceId, Quantity = 1 }
|
||||
],
|
||||
Mode = "subscription",
|
||||
SubscriptionData = new SessionSubscriptionDataOptions
|
||||
{
|
||||
ApplicationFeePercent = stripeOptions.Value.HutopyRate,
|
||||
TransferData =
|
||||
new SessionSubscriptionDataTransferDataOptions
|
||||
{
|
||||
Destination = creatorReference.StripeAccountId
|
||||
}
|
||||
},
|
||||
SuccessUrl = successUrl, // Redirect after successful payment
|
||||
CancelUrl = cancelUrl, // Redirect after canceled payment
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "userId", userId.ToString() },
|
||||
{ "creatorId", creatorReference.Id.ToString() },
|
||||
{ "creatorName", creatorReference.Name },
|
||||
{ "tierId", tierId.ToString() }
|
||||
}
|
||||
});
|
||||
|
||||
return new MembershipCheckoutSession(
|
||||
session.Id,
|
||||
session.Url);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
using Hutopy.Infrastructure.Payments.Stripe.Configuration;
|
||||
using Hutopy.Modules.Memberships.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Stripe;
|
||||
|
||||
namespace Hutopy.Infrastructure.Payments.Stripe.Services;
|
||||
|
||||
public sealed class MembershipTierProcessor(
|
||||
IOptions<StripeOptions> stripeOptions)
|
||||
: IMembershipTierProcessor
|
||||
{
|
||||
public async Task<string> CreateAsync(
|
||||
Guid creatorId,
|
||||
Guid tierId,
|
||||
string productName,
|
||||
string currencyCode,
|
||||
decimal amount)
|
||||
{
|
||||
StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey;
|
||||
|
||||
// Create the product
|
||||
ProductService productService = new();
|
||||
Product? product = await productService.CreateAsync(
|
||||
new ProductCreateOptions
|
||||
{
|
||||
Name = productName,
|
||||
Metadata = { { "creatorId", creatorId.ToString() }, { "tierId", tierId.ToString() } }
|
||||
});
|
||||
|
||||
// Create the price for the product
|
||||
PriceService priceService = new();
|
||||
await priceService.CreateAsync(
|
||||
new PriceCreateOptions
|
||||
{
|
||||
Product = product.Id,
|
||||
UnitAmountDecimal = amount * 100, // Convert amount to cents
|
||||
Currency = currencyCode,
|
||||
Recurring = new PriceRecurringOptions { Interval = "month" }
|
||||
});
|
||||
|
||||
return product.Id;
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
using Hutopy.Infrastructure.Payments.Stripe.Configuration;
|
||||
using Hutopy.Modules.Creators.Contracts;
|
||||
using Hutopy.Modules.Tipping.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
|
||||
namespace Hutopy.Infrastructure.Payments.Stripe.Services;
|
||||
|
||||
internal class StripeTipProcessor(
|
||||
IOptions<StripeOptions> stripeOptions)
|
||||
: ITipProcessor
|
||||
{
|
||||
public async Task<TipCheckoutSession> CreateCheckoutSessionAsync(
|
||||
Guid tipId,
|
||||
CreatorReference creator,
|
||||
decimal amount,
|
||||
string currency,
|
||||
string message,
|
||||
Uri successUrl,
|
||||
Uri cancelUrl,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var applicationFeeAmount = Convert.ToInt64(amount * stripeOptions.Value.HutopyRate);
|
||||
|
||||
StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey;
|
||||
|
||||
var sessionService = new SessionService();
|
||||
|
||||
var options = new SessionCreateOptions
|
||||
{
|
||||
ClientReferenceId = tipId.ToString(),
|
||||
Mode = "payment",
|
||||
LineItems =
|
||||
[
|
||||
new SessionLineItemOptions
|
||||
{
|
||||
PriceData = new SessionLineItemPriceDataOptions
|
||||
{
|
||||
Currency = currency,
|
||||
UnitAmountDecimal = amount, // Amount in cents
|
||||
ProductData = new SessionLineItemPriceDataProductDataOptions
|
||||
{
|
||||
Name = $"Tip for {creator.Name}",
|
||||
Metadata = new Dictionary<string, string> { { "creatorId", creator.Id.ToString() } }
|
||||
}
|
||||
},
|
||||
Quantity = 1
|
||||
}
|
||||
],
|
||||
PaymentIntentData = new SessionPaymentIntentDataOptions { ApplicationFeeAmount = applicationFeeAmount },
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "creatorId", creator.Id.ToString() }, { "creatorName", creator.Name }, { "message", message }
|
||||
},
|
||||
SuccessUrl = successUrl.ToString(), // Redirect after successful payment
|
||||
CancelUrl = cancelUrl.ToString(), // Redirect after canceled payment
|
||||
};
|
||||
|
||||
var requestOptions = new RequestOptions { StripeAccount = creator.StripeAccountId };
|
||||
|
||||
var session = await sessionService.CreateAsync(
|
||||
options,
|
||||
requestOptions,
|
||||
cancellationToken: ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new TipCheckoutSession(session.Id, session.Url);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace Hutopy.Infrastructure.Security;
|
||||
|
||||
public static class KnownClaims
|
||||
{
|
||||
public const string Alias = "alias";
|
||||
public const string PortraitUrl = "portraitUrl";
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Hutopy.Common.Domain;
|
||||
|
||||
namespace Hutopy.Modules.Contents.Data;
|
||||
|
||||
public class Album : Entity
|
||||
{
|
||||
public bool IsDeleted { get; private set; } // private set → EF updates it
|
||||
[MaxLength(255)] public required string Title { get; set; }
|
||||
public IList<AlbumPhoto> Photos { get; set; } = new List<AlbumPhoto>();
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Hutopy.Common.Domain;
|
||||
|
||||
namespace Hutopy.Modules.Contents.Data;
|
||||
|
||||
public class AlbumPhoto : Entity
|
||||
{
|
||||
public bool IsDeleted { get; private set; } // private set → EF updates it
|
||||
public Guid AlbumId { get; set; }
|
||||
public Album Album { get; init; } = null!;
|
||||
[MaxLength(2048)] public required string OriginalUrl { get; set; }
|
||||
[MaxLength(2048)] public required string ThumbnailUrl { get; set; }
|
||||
[MaxLength(256)] public string? Caption { get; set; }
|
||||
public int Order { get; set; }
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
namespace Hutopy.Modules.Contents.Data;
|
||||
|
||||
public class ContentsDbContext(
|
||||
DbContextOptions<ContentsDbContext> options)
|
||||
: DbContext(options)
|
||||
{
|
||||
public const string SchemaName = "Content";
|
||||
|
||||
public DbSet<Album> Albums => Set<Album>();
|
||||
public DbSet<AlbumPhoto> AlbumPhotos => Set<AlbumPhoto>();
|
||||
|
||||
protected override void OnModelCreating(
|
||||
ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema(SchemaName);
|
||||
|
||||
// Album configuration
|
||||
modelBuilder
|
||||
.Entity<Album>()
|
||||
.Property(c => c.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
modelBuilder
|
||||
.Entity<Album>()
|
||||
.Property(c => c.IsDeleted)
|
||||
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
|
||||
|
||||
modelBuilder
|
||||
.Entity<Album>()
|
||||
.HasQueryFilter(a => !a.IsDeleted);
|
||||
|
||||
// AlbumPhoto configuration
|
||||
modelBuilder
|
||||
.Entity<AlbumPhoto>()
|
||||
.Property(c => c.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
modelBuilder
|
||||
.Entity<AlbumPhoto>()
|
||||
.Property(c => c.IsDeleted)
|
||||
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
|
||||
|
||||
modelBuilder
|
||||
.Entity<AlbumPhoto>()
|
||||
.HasOne(ap => ap.Album)
|
||||
.WithMany(a => a.Photos)
|
||||
.HasForeignKey(ap => ap.AlbumId)
|
||||
.IsRequired();
|
||||
|
||||
modelBuilder
|
||||
.Entity<AlbumPhoto>()
|
||||
.HasQueryFilter(ap => !ap.IsDeleted);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using Hutopy.Modules.Contents.Data;
|
||||
|
||||
namespace Hutopy.Modules.Contents;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddContentModule(
|
||||
this WebApplicationBuilder builder,
|
||||
Action<DbContextOptionsBuilder>? configureAction = null)
|
||||
{
|
||||
builder.Services.AddDbContext<ContentsDbContext>(configureAction);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static async Task<IApplicationBuilder> UseContentModuleAsync(
|
||||
this IApplicationBuilder app,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IServiceScopeFactory scopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>();
|
||||
using IServiceScope scope = scopeFactory.CreateScope();
|
||||
await using ContentsDbContext context = scope.ServiceProvider.GetRequiredService<ContentsDbContext>();
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
using Hutopy.Infrastructure.BlobStorage.Contracts;
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Contents.Data;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
|
||||
namespace Hutopy.Modules.Contents.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record AddPhotoToAlbumRequest(
|
||||
Guid AlbumId,
|
||||
Guid PhotoId,
|
||||
IFormFile File,
|
||||
string? Caption = null);
|
||||
|
||||
[PublicAPI]
|
||||
public record AddPhotoToAlbumResponse(
|
||||
Guid PhotoId,
|
||||
string OriginalUrl,
|
||||
string ThumbnailUrl);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class AddPhotoToAlbumRequestValidator : Validator<AddPhotoToAlbumRequest>
|
||||
{
|
||||
private const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
private static readonly string[] AllowedImageTypes =
|
||||
[
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp"
|
||||
];
|
||||
|
||||
public AddPhotoToAlbumRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.AlbumId)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
|
||||
RuleFor(x => x.PhotoId)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
|
||||
RuleFor(x => x.File)
|
||||
.NotNull()
|
||||
.NotEmpty()
|
||||
.Must(file => AllowedImageTypes.Contains(file.ContentType))
|
||||
.WithMessage("File must be a valid image (JPEG, PNG, GIF, or WebP)")
|
||||
.Must(file => file.Length <= MaxFileSizeBytes)
|
||||
.WithMessage($"File size must not exceed {MaxFileSizeBytes / 1024 / 1024}MB");
|
||||
|
||||
RuleFor(x => x.Caption)
|
||||
.MaximumLength(255);
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class AddPhotoToAlbumHandler(
|
||||
ContentsDbContext context,
|
||||
IBlobStorage blobStorage)
|
||||
: Endpoint<AddPhotoToAlbumRequest, AddPhotoToAlbumResponse>
|
||||
{
|
||||
private const int MaxThumbnailWidth = 500;
|
||||
private const int MaxThumbnailHeight = 500;
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/albums/{AlbumId}/photos");
|
||||
Options(o => o.WithTags("Albums"));
|
||||
AllowFileUploads();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
AddPhotoToAlbumRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Guid userId = User.GetUserId();
|
||||
|
||||
// Fetch the album we want to add photos to
|
||||
Album? album = await context
|
||||
.Albums
|
||||
.SingleOrDefaultAsync(
|
||||
a => a.Id == request.AlbumId && a.CreatedBy == userId,
|
||||
ct);
|
||||
|
||||
if (album is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if a photo with the same ID already exists
|
||||
bool existingPhoto = await context
|
||||
.AlbumPhotos
|
||||
.AnyAsync(p => p.Id == request.PhotoId, ct);
|
||||
|
||||
if (existingPhoto)
|
||||
{
|
||||
await SendErrorsAsync(409, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
(string originalUrl, string thumbnailUrl) = await ProcessAndUploadImage(request, ct);
|
||||
|
||||
// Get the next order number
|
||||
int nextOrder = await context
|
||||
.AlbumPhotos
|
||||
.Where(p => p.AlbumId == request.AlbumId)
|
||||
.MaxAsync(p => (int?)p.Order, ct) ?? 0;
|
||||
|
||||
// Create the album photo
|
||||
AlbumPhoto photo = new()
|
||||
{
|
||||
Id = request.PhotoId,
|
||||
CreatedBy = userId,
|
||||
AlbumId = request.AlbumId,
|
||||
OriginalUrl = originalUrl,
|
||||
ThumbnailUrl = thumbnailUrl,
|
||||
Caption = request.Caption,
|
||||
Order = nextOrder + 1
|
||||
};
|
||||
|
||||
context.AlbumPhotos.Add(photo);
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(
|
||||
new AddPhotoToAlbumResponse(photo.Id, originalUrl, thumbnailUrl),
|
||||
ct);
|
||||
}
|
||||
catch (UnknownImageFormatException)
|
||||
{
|
||||
await SendStringAsync("Invalid image format", 400, cancellation: ct);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await SendStringAsync("Error processing image", 500, cancellation: ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(string originalUrl, string thumbnailUrl)> ProcessAndUploadImage(
|
||||
AddPhotoToAlbumRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string originalFileName = Path.GetFileName(request.File.FileName);
|
||||
string nameWithoutExt = Path.GetFileNameWithoutExtension(originalFileName);
|
||||
string extension = Path.GetExtension(originalFileName);
|
||||
|
||||
string filenameOriginal = $"{nameWithoutExt}{extension}";
|
||||
string filenameThumbnail = $"{nameWithoutExt}.thumbnail{extension}";
|
||||
|
||||
string blobOriginal = $"{SubDirectoryNames.Albums}/{request.AlbumId}/{filenameOriginal}";
|
||||
string blobThumbnail = $"{SubDirectoryNames.Albums}/{request.AlbumId}/{filenameThumbnail}";
|
||||
|
||||
// Process the original image
|
||||
await using Stream originalStream = request.File.OpenReadStream();
|
||||
using Image image = await Image.LoadAsync(originalStream, ct);
|
||||
|
||||
// Calculate target size while preserving the original aspect ratio
|
||||
int originalWidth = image.Width;
|
||||
int originalHeight = image.Height;
|
||||
|
||||
double ratioX = (double)MaxThumbnailWidth / originalWidth;
|
||||
double ratioY = (double)MaxThumbnailHeight / originalHeight;
|
||||
double ratio = Math.Min(ratioX, ratioY);
|
||||
|
||||
int newWidth = (int)(originalWidth * ratio);
|
||||
int newHeight = (int)(originalHeight * ratio);
|
||||
|
||||
// Create thumbnail
|
||||
using MemoryStream thumbnailStream = new();
|
||||
image.Mutate(x => x.Resize(newWidth, newHeight));
|
||||
await image.SaveAsync(thumbnailStream, image.Metadata.DecodedImageFormat!, ct);
|
||||
thumbnailStream.Position = 0;
|
||||
|
||||
// Upload both versions
|
||||
string originalUrl = await blobStorage.UploadFileAsync(
|
||||
ContainerNames.Creators,
|
||||
blobOriginal,
|
||||
request.File.OpenReadStream(),
|
||||
request.File.ContentType,
|
||||
ct);
|
||||
|
||||
string thumbnailUrl = await blobStorage.UploadFileAsync(
|
||||
ContainerNames.Creators,
|
||||
blobThumbnail,
|
||||
thumbnailStream,
|
||||
request.File.ContentType,
|
||||
ct);
|
||||
|
||||
return (originalUrl, thumbnailUrl);
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Contents.Data;
|
||||
|
||||
namespace Hutopy.Modules.Contents.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record CreateAlbumRequest(
|
||||
Guid AlbumId,
|
||||
string Title,
|
||||
string? Description = null);
|
||||
|
||||
[PublicAPI]
|
||||
public record CreateAlbumResponse(
|
||||
Guid AlbumId);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class CreateAlbumRequestValidator : Validator<CreateAlbumRequest>
|
||||
{
|
||||
public CreateAlbumRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.AlbumId)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
|
||||
RuleFor(x => x.Title)
|
||||
.NotNull()
|
||||
.NotEmpty()
|
||||
.MaximumLength(255);
|
||||
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(1000);
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class CreateAlbumHandler(
|
||||
ContentsDbContext context)
|
||||
: Endpoint<CreateAlbumRequest, CreateAlbumResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/albums");
|
||||
Options(o => o.WithTags("Albums"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CreateAlbumRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Check if an album with the same ID already exists
|
||||
bool existingAlbum = await context
|
||||
.Albums
|
||||
.AnyAsync(a => a.Id == request.AlbumId, ct);
|
||||
|
||||
if (existingAlbum)
|
||||
{
|
||||
await SendErrorsAsync(409, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Album album = new() { Id = request.AlbumId, CreatedBy = User.GetUserId(), Title = request.Title };
|
||||
|
||||
context.Albums.Add(album);
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(
|
||||
new CreateAlbumResponse(album.Id),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
using Hutopy.Modules.Contents.Data;
|
||||
|
||||
namespace Hutopy.Modules.Contents.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record GetAlbumRequest(
|
||||
Guid AlbumId);
|
||||
|
||||
[PublicAPI]
|
||||
public record AlbumPhotoDto(
|
||||
Guid Id,
|
||||
string OriginalUrl,
|
||||
string ThumbnailUrl,
|
||||
string? Caption,
|
||||
int Order,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
[PublicAPI]
|
||||
public record GetAlbumResponse(
|
||||
Guid Id,
|
||||
string Title,
|
||||
IReadOnlyList<AlbumPhotoDto> Photos,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class GetAlbumRequestValidator : Validator<GetAlbumRequest>
|
||||
{
|
||||
public GetAlbumRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.AlbumId)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class GetAlbumHandler(
|
||||
ContentsDbContext context)
|
||||
: Endpoint<GetAlbumRequest, GetAlbumResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
AllowAnonymous();
|
||||
Get("/api/albums/{AlbumId}");
|
||||
Options(o => o.WithTags("Albums"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
GetAlbumRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Album? album = await context
|
||||
.Albums
|
||||
.Include(a => a.Photos.OrderBy(p => p.Order))
|
||||
.SingleOrDefaultAsync(
|
||||
a => a.Id == request.AlbumId,
|
||||
ct);
|
||||
|
||||
if (album is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
List<AlbumPhotoDto> photos = album.Photos
|
||||
.Select(p => new AlbumPhotoDto(
|
||||
p.Id,
|
||||
p.OriginalUrl,
|
||||
p.ThumbnailUrl,
|
||||
p.Caption,
|
||||
p.Order,
|
||||
p.CreatedAt))
|
||||
.ToList();
|
||||
|
||||
await SendOkAsync(
|
||||
new GetAlbumResponse(
|
||||
album.Id,
|
||||
album.Title,
|
||||
photos,
|
||||
album.CreatedAt),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Contents.Data;
|
||||
|
||||
namespace Hutopy.Modules.Contents.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record RemoveAlbumRequest(
|
||||
Guid AlbumId);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class RemoveAlbumRequestValidator : Validator<RemoveAlbumRequest>
|
||||
{
|
||||
public RemoveAlbumRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.AlbumId)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class RemoveAlbumHandler(
|
||||
ContentsDbContext context)
|
||||
: Endpoint<RemoveAlbumRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/albums/{AlbumId}");
|
||||
Options(o => o.WithTags("Albums"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
RemoveAlbumRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Guid userId = User.GetUserId();
|
||||
|
||||
Album? album = await context
|
||||
.Albums
|
||||
.Include(a => a.Photos)
|
||||
.SingleOrDefaultAsync(
|
||||
a => a.Id == request.AlbumId && a.CreatedBy == userId,
|
||||
ct);
|
||||
|
||||
if (album is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Soft delete the album
|
||||
album.DeletedBy = userId;
|
||||
album.DeletedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// Soft delete all photos in the album
|
||||
foreach (AlbumPhoto photo in album.Photos)
|
||||
{
|
||||
photo.DeletedBy = userId;
|
||||
photo.DeletedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendNoContentAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Contents.Data;
|
||||
|
||||
namespace Hutopy.Modules.Contents.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record RemovePhotoFromAlbumRequest(
|
||||
Guid AlbumId,
|
||||
Guid PhotoId);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class RemovePhotoFromAlbumRequestValidator : Validator<RemovePhotoFromAlbumRequest>
|
||||
{
|
||||
public RemovePhotoFromAlbumRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.AlbumId)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
|
||||
RuleFor(x => x.PhotoId)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class RemovePhotoFromAlbumHandler(
|
||||
ContentsDbContext context)
|
||||
: Endpoint<RemovePhotoFromAlbumRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/albums/{AlbumId}/photos/{PhotoId}");
|
||||
Options(o => o.WithTags("Albums"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
RemovePhotoFromAlbumRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Guid userId = User.GetUserId();
|
||||
|
||||
Album? album = await context
|
||||
.Albums
|
||||
.Include(a => a.Photos)
|
||||
.SingleOrDefaultAsync(
|
||||
a => a.Id == request.AlbumId && a.CreatedBy == userId,
|
||||
ct);
|
||||
|
||||
if (album is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
AlbumPhoto? photo = album.Photos
|
||||
.SingleOrDefault(p => p.Id == request.PhotoId);
|
||||
|
||||
if (photo is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Soft delete the photo
|
||||
photo.DeletedBy = userId;
|
||||
photo.DeletedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendNoContentAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Hutopy.Modules.Contents.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Modules.Contents.Migrations
|
||||
{
|
||||
[DbContext(typeof(ContentsDbContext))]
|
||||
[Migration("20250609212411_Initial")]
|
||||
partial class Initial
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("Content")
|
||||
.HasAnnotation("ProductVersion", "9.0.3")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Contents.Data.Album", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("boolean")
|
||||
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Albums", "Content");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Contents.Data.AlbumPhoto", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AlbumId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Caption")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("boolean")
|
||||
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("OriginalUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("ThumbnailUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AlbumId");
|
||||
|
||||
b.ToTable("AlbumPhotos", "Content");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Contents.Data.AlbumPhoto", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Modules.Contents.Data.Album", "Album")
|
||||
.WithMany("Photos")
|
||||
.HasForeignKey("AlbumId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Album");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Contents.Data.Album", b =>
|
||||
{
|
||||
b.Navigation("Photos");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Modules.Contents.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Initial : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "Content");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Albums",
|
||||
schema: "Content",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "boolean", nullable: false, computedColumnSql: "\"DeletedAt\" IS NOT NULL", stored: true),
|
||||
Title = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
|
||||
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
|
||||
DeletedBy = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Albums", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AlbumPhotos",
|
||||
schema: "Content",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "boolean", nullable: false, computedColumnSql: "\"DeletedAt\" IS NOT NULL", stored: true),
|
||||
AlbumId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
OriginalUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
|
||||
ThumbnailUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
|
||||
Caption = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
Order = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
|
||||
DeletedBy = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AlbumPhotos", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AlbumPhotos_Albums_AlbumId",
|
||||
column: x => x.AlbumId,
|
||||
principalSchema: "Content",
|
||||
principalTable: "Albums",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AlbumPhotos_AlbumId",
|
||||
schema: "Content",
|
||||
table: "AlbumPhotos",
|
||||
column: "AlbumId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AlbumPhotos",
|
||||
schema: "Content");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Albums",
|
||||
schema: "Content");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Hutopy.Modules.Contents.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Modules.Contents.Migrations
|
||||
{
|
||||
[DbContext(typeof(ContentsDbContext))]
|
||||
partial class ContentsDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("Content")
|
||||
.HasAnnotation("ProductVersion", "9.0.3")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Contents.Data.Album", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("boolean")
|
||||
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Albums", "Content");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Contents.Data.AlbumPhoto", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AlbumId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Caption")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("boolean")
|
||||
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("OriginalUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("ThumbnailUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AlbumId");
|
||||
|
||||
b.ToTable("AlbumPhotos", "Content");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Contents.Data.AlbumPhoto", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Modules.Contents.Data.Album", "Album")
|
||||
.WithMany("Photos")
|
||||
.HasForeignKey("AlbumId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Album");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Contents.Data.Album", b =>
|
||||
{
|
||||
b.Navigation("Photos");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
namespace Hutopy.Modules.Contents.Models;
|
||||
|
||||
[PublicAPI]
|
||||
public class ContentModel
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required Guid CreatedBy { get; init; }
|
||||
public required string CreatedByName { get; init; }
|
||||
public required string? CreatedByPortraitUrl { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public Guid? DeletedBy { get; init; }
|
||||
public DateTimeOffset? DeletedAt { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public string HtmlFileUrl { get; init; } = "";
|
||||
public required string[]? Urls { get; init; }
|
||||
public string? ThumbnailUrl { get; init; }
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace Hutopy.Modules.Contents.Models;
|
||||
|
||||
[PublicAPI]
|
||||
public record FollowModel(
|
||||
Guid CreatorId,
|
||||
string CreatorName,
|
||||
string? CreatorPortraitUrl);
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Hutopy.Modules.Creators.Configuration;
|
||||
|
||||
public class CreatorOptions
|
||||
{
|
||||
public const string ConfigurationSection = "Creators";
|
||||
|
||||
public TimeSpan SlugReservationDuration { get; set; }
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Hutopy.Modules.Creators.Contracts;
|
||||
|
||||
public record CreatorReference(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? PortraitUrl,
|
||||
bool OnboardingComplete,
|
||||
bool AcceptCharges,
|
||||
string? StripeAccountId);
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Hutopy.Modules.Creators.Contracts;
|
||||
|
||||
public interface ICreatorLookup
|
||||
{
|
||||
Task<CreatorReference?> GetCreatorAsync(Guid creatorId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Data;
|
||||
|
||||
public class Creator
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid CreatedBy { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public Guid? DeletedBy { get; set; }
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Soft‑delete flag (false by default, true once DeletedAt is set)
|
||||
/// </summary>
|
||||
public bool IsDeleted { get; private set; } // private set → EF updates it
|
||||
|
||||
[MaxLength(2048)] public string? BannerUrl { get; set; }
|
||||
[MaxLength(2048)] public string? PortraitUrl { get; set; }
|
||||
public bool Verified { get; set; }
|
||||
[MaxLength(256)] public required string Name { get; set; }
|
||||
[MaxLength(128)] public required string Slug { get; set; }
|
||||
[MaxLength(256)] public string? Title { get; set; }
|
||||
|
||||
[MaxLength(21)] public string? StripeAccountId { get; set; }
|
||||
public bool IsStripeDetailsSubmitted { get; set; }
|
||||
public bool IsStripePayoutReady { get; set; }
|
||||
public bool IsStripeChargesEnabled { get; set; }
|
||||
public Socials Socials { get; set; } = new();
|
||||
public Presentation Presentation { get; set; } = new() { Description = "Welcome to my profile!" };
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
namespace Hutopy.Modules.Creators.Data;
|
||||
|
||||
public class CreatorsDbContext(
|
||||
DbContextOptions<CreatorsDbContext> options)
|
||||
: DbContext(options)
|
||||
{
|
||||
public const string SchemaName = "Creators";
|
||||
|
||||
public DbSet<Creator> Creators => Set<Creator>();
|
||||
public DbSet<Slugs> Slugs => Set<Slugs>();
|
||||
|
||||
protected override void OnModelCreating(
|
||||
ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema(SchemaName);
|
||||
|
||||
modelBuilder
|
||||
.Entity<Slugs>()
|
||||
.Property(x => x.NormalizedName)
|
||||
.HasComputedColumnSql("LOWER(\"Name\")", true);
|
||||
|
||||
modelBuilder
|
||||
.Entity<Slugs>()
|
||||
.HasIndex(x => x.NormalizedName)
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder
|
||||
.Entity<Creator>()
|
||||
.Property(c => c.IsDeleted)
|
||||
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true); // bool
|
||||
|
||||
modelBuilder
|
||||
.Entity<Creator>()
|
||||
.OwnsOne<Socials>(x => x.Socials)
|
||||
.ToTable(nameof(Socials));
|
||||
|
||||
modelBuilder
|
||||
.Entity<Creator>()
|
||||
.OwnsOne<Presentation>(x => x.Presentation)
|
||||
.ToTable(nameof(Presentation));
|
||||
|
||||
modelBuilder
|
||||
.Entity<Creator>()
|
||||
.HasQueryFilter(c => !c.IsDeleted);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Data;
|
||||
|
||||
public class Presentation
|
||||
{
|
||||
public string Description { get; set; } = null!;
|
||||
[MaxLength(2048)] public string? VideoUrl { get; set; }
|
||||
[MaxLength(256)] public string? PhoneNumber { get; set; }
|
||||
[MaxLength(256)] public string? Email { get; set; }
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Data;
|
||||
|
||||
public class Slugs
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid CreatedBy { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public Guid? UsedBy { get; set; }
|
||||
[MaxLength(128)] public string Name { get; set; } = null!;
|
||||
[MaxLength(128)] public string NormalizedName { get; set; } = null!;
|
||||
public DateTimeOffset ReservedUntil { get; set; }
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Data;
|
||||
|
||||
public class Socials
|
||||
{
|
||||
[MaxLength(2048)] public string? FacebookUrl { get; set; }
|
||||
[MaxLength(2048)] public string? InstagramUrl { get; set; }
|
||||
[MaxLength(2048)] public string? XUrl { get; set; }
|
||||
[MaxLength(2048)] public string? LinkedInUrl { get; set; }
|
||||
[MaxLength(2048)] public string? TikTokUrl { get; set; }
|
||||
[MaxLength(2048)] public string? YoutubeUrl { get; set; }
|
||||
[MaxLength(2048)] public string? RedditUrl { get; set; }
|
||||
[MaxLength(2048)] public string? WebsiteUrl { get; set; }
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using Hutopy.Modules.Creators.Configuration;
|
||||
using Hutopy.Modules.Creators.Contracts;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
using Hutopy.Modules.Creators.Services;
|
||||
|
||||
namespace Hutopy.Modules.Creators;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddCreatorModule(
|
||||
this WebApplicationBuilder builder,
|
||||
Action<DbContextOptionsBuilder>? configureAction = null)
|
||||
{
|
||||
builder.Services.Configure<CreatorOptions>(
|
||||
builder.Configuration.GetSection(CreatorOptions.ConfigurationSection));
|
||||
builder.Services.AddScoped<SlugPurger>();
|
||||
|
||||
builder.Services.AddDbContext<CreatorsDbContext>(configureAction);
|
||||
builder.Services.AddTransient<ICreatorLookup, CreatorLookup>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static async Task<IApplicationBuilder> UseCreatorModuleAsync(
|
||||
this IApplicationBuilder app,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IServiceScopeFactory scopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>();
|
||||
using IServiceScope scope = scopeFactory.CreateScope();
|
||||
await using CreatorsDbContext context = scope.ServiceProvider.GetRequiredService<CreatorsDbContext>();
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
using Hutopy.Infrastructure.BlobStorage.Contracts;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public static class ChangeBanner
|
||||
{
|
||||
public record Request(
|
||||
Guid CreatorId,
|
||||
IFormFile File);
|
||||
|
||||
public record Response(
|
||||
string BlobUrl);
|
||||
|
||||
public class Handler(
|
||||
CreatorsDbContext context,
|
||||
IBlobStorage blobStorage)
|
||||
: Endpoint<Request, Response>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/creators/{CreatorId}/banner");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
AllowFileUploads();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
Request request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Creator? creator = await context
|
||||
.Creators
|
||||
.SingleOrDefaultAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
ct);
|
||||
|
||||
if (creator is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string blobUrl = await blobStorage.UploadFileAsync(
|
||||
ContainerNames.Creators,
|
||||
$"{request.CreatorId}/{SubDirectoryNames.Profile}/{CommonFileNames.BannerPicture}",
|
||||
request.File.OpenReadStream(),
|
||||
request.File.ContentType,
|
||||
ct);
|
||||
|
||||
creator.BannerUrl = $"{blobUrl}?t={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(
|
||||
new Response(blobUrl),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeEmailRequest(
|
||||
Guid CreatorId,
|
||||
string? Email);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class ChangeEmailRequestValidator : Validator<ChangeEmailRequest>
|
||||
{
|
||||
public ChangeEmailRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.CreatorId)
|
||||
.NotEmpty()
|
||||
.WithMessage("Creator ID is required");
|
||||
|
||||
RuleFor(x => x.Email)
|
||||
.Must(email => email == null || !string.IsNullOrWhiteSpace(email))
|
||||
.WithMessage("Email cannot be empty if provided");
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangeEmailHandler(
|
||||
CreatorsDbContext context)
|
||||
: Endpoint<ChangeEmailRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/creators/{CreatorId}/email");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ChangeEmailRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Creator? creator = await context
|
||||
.Creators
|
||||
.Include(c => c.Presentation)
|
||||
.SingleOrDefaultAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
ct);
|
||||
|
||||
if (creator is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the current user is the creator
|
||||
if (creator.CreatedBy != User.GetUserId())
|
||||
{
|
||||
await SendUnauthorizedAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
creator.Presentation.Email = request.Email?.Trim();
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
using Hutopy.Infrastructure.BlobStorage.Contracts;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeLogoRequest(
|
||||
Guid CreatorId,
|
||||
IFormFile File);
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeLogoResponse(
|
||||
string BlobUrl);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class ChangeLogoRequestValidator : Validator<ChangeLogoRequest>
|
||||
{
|
||||
public ChangeLogoRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.CreatorId)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
|
||||
RuleFor(x => x.File)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangeLogoHandler(
|
||||
CreatorsDbContext context,
|
||||
IBlobStorage blobStorage)
|
||||
: Endpoint<ChangeLogoRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/creators/{CreatorId}/logo");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
AllowFileUploads();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ChangeLogoRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Creator? creator = await context
|
||||
.Creators
|
||||
.SingleOrDefaultAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
ct);
|
||||
|
||||
if (creator is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string blobUrl = await blobStorage.UploadFileAsync(
|
||||
ContainerNames.Creators,
|
||||
$"{request.CreatorId}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}",
|
||||
request.File.OpenReadStream(),
|
||||
request.File.ContentType,
|
||||
ct);
|
||||
|
||||
creator.PortraitUrl = $"{blobUrl}?t={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(
|
||||
new ChangeLogoResponse(blobUrl),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeNameRequest(
|
||||
Guid CreatorId,
|
||||
string Name);
|
||||
|
||||
[PublicAPI]
|
||||
internal sealed class ChangeNameRequestValidator
|
||||
: Validator<ChangeNameRequest>
|
||||
{
|
||||
public ChangeNameRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.Name)
|
||||
.NotNull().WithMessage("You should specify the Name")
|
||||
.NotEmpty().WithMessage("You should specify a valid/not empty Name");
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangeNameHandler(
|
||||
CreatorsDbContext context)
|
||||
: Endpoint<ChangeNameRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/creators/{CreatorId}/name");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ChangeNameRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Creator creator = await context
|
||||
.Creators
|
||||
.SingleAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
ct);
|
||||
|
||||
creator.Name = request.Name;
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangePhoneNumberRequest(
|
||||
Guid CreatorId,
|
||||
string? PhoneNumber);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class ChangePhoneNumberRequestValidator : Validator<ChangePhoneNumberRequest>
|
||||
{
|
||||
public ChangePhoneNumberRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.CreatorId)
|
||||
.NotEmpty()
|
||||
.WithMessage("Creator ID is required");
|
||||
|
||||
RuleFor(x => x.PhoneNumber)
|
||||
.Must(phone => phone == null || !string.IsNullOrWhiteSpace(phone))
|
||||
.WithMessage("Phone number cannot be empty if provided");
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangePhoneNumberHandler(
|
||||
CreatorsDbContext context)
|
||||
: Endpoint<ChangePhoneNumberRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/creators/{CreatorId}/phone");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ChangePhoneNumberRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Creator? creator = await context
|
||||
.Creators
|
||||
.Include(c => c.Presentation)
|
||||
.SingleOrDefaultAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
ct);
|
||||
|
||||
if (creator is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the current user is the creator
|
||||
if (creator.CreatedBy != User.GetUserId())
|
||||
{
|
||||
await SendUnauthorizedAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
creator.Presentation.PhoneNumber = request.PhoneNumber?.Trim();
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
using Hutopy.Infrastructure.YouTube;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangePresentationInfosRequest(
|
||||
Guid CreatorId,
|
||||
string Description,
|
||||
string? VideoUrl);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class ChangePresentationInfosRequestValidator : Validator<ChangePresentationInfosRequest>
|
||||
{
|
||||
public ChangePresentationInfosRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.CreatorId)
|
||||
.NotEmpty()
|
||||
.WithMessage("Creator ID is required");
|
||||
|
||||
RuleFor(x => x.Description)
|
||||
.NotEmpty()
|
||||
.WithMessage("Description is required")
|
||||
.MaximumLength(2000)
|
||||
.WithMessage("Description cannot exceed 2000 characters");
|
||||
|
||||
RuleFor(x => x.VideoUrl)
|
||||
.Must(url => url == null || YouTubeUrlHelper.IsValidYouTubeUrlOrId(url))
|
||||
.WithMessage("Invalid YouTube URL or video ID format");
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangePresentationInfosHandler(
|
||||
CreatorsDbContext context)
|
||||
: Endpoint<ChangePresentationInfosRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/creators/{CreatorId}/presentation-infos");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ChangePresentationInfosRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Creator? creator = await context
|
||||
.Creators
|
||||
.Include(c => c.Presentation)
|
||||
.SingleOrDefaultAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
ct);
|
||||
|
||||
if (creator is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the presentation info with the new values
|
||||
creator.Presentation.Description = request.Description.Trim();
|
||||
creator.Presentation.VideoUrl = request.VideoUrl != null
|
||||
? YouTubeUrlHelper.ExtractVideoId(request.VideoUrl.Trim())
|
||||
: null;
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeSlugRequest(
|
||||
Guid CreatorId,
|
||||
Guid SlugReservationId);
|
||||
|
||||
[PublicAPI]
|
||||
internal sealed class ChangeSlugRequestValidator
|
||||
: Validator<ChangeSlugRequest>
|
||||
{
|
||||
public ChangeSlugRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.CreatorId)
|
||||
.NotNull().WithMessage("You should specify the CreatorId")
|
||||
.NotEmpty().WithMessage("You should specify a valid/not empty CreatorId");
|
||||
|
||||
RuleFor(r => r.SlugReservationId)
|
||||
.NotNull().WithMessage("You should specify the SlugReservationId")
|
||||
.NotEmpty().WithMessage("You should specify a valid/not empty SlugReservationId");
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangeSlugHandler(
|
||||
CreatorsDbContext context)
|
||||
: Endpoint<ChangeSlugRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/creators/{CreatorId}/slug");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ChangeSlugRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using IDbContextTransaction transaction = await context.Database.BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
Creator creator = await context
|
||||
.Creators
|
||||
.SingleAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
ct);
|
||||
|
||||
if (creator.CreatedBy != User.GetUserId())
|
||||
{
|
||||
await SendUnauthorizedAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Slugs? reservation = await context
|
||||
.Slugs
|
||||
.FirstOrDefaultAsync(
|
||||
s => s.Id == request.SlugReservationId,
|
||||
ct);
|
||||
|
||||
if (reservation is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Slugs? previousReservation = await context
|
||||
.Slugs
|
||||
.FirstOrDefaultAsync(
|
||||
s => s.UsedBy == request.CreatorId,
|
||||
ct);
|
||||
|
||||
if (previousReservation is null)
|
||||
{
|
||||
await SendErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Remove(previousReservation);
|
||||
reservation.UsedBy = creator.Id;
|
||||
creator.Slug = reservation.NormalizedName;
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeSocialsRequest(
|
||||
Guid CreatorId,
|
||||
string? FacebookUrl,
|
||||
string? InstagramUrl,
|
||||
string? XUrl,
|
||||
string? LinkedInUrl,
|
||||
string? TikTokUrl,
|
||||
string? YoutubeUrl,
|
||||
string? RedditUrl,
|
||||
string? WebsiteUrl);
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangeSocialsHandler(
|
||||
CreatorsDbContext context)
|
||||
: Endpoint<ChangeSocialsRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/creators/{CreatorId}/socials");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ChangeSocialsRequest request, CancellationToken ct)
|
||||
{
|
||||
Creator creator = await context
|
||||
.Creators
|
||||
.Include(c => c.Socials)
|
||||
.SingleAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
ct);
|
||||
|
||||
creator.Socials.FacebookUrl = request.FacebookUrl;
|
||||
creator.Socials.InstagramUrl = request.InstagramUrl;
|
||||
creator.Socials.XUrl = request.XUrl;
|
||||
creator.Socials.LinkedInUrl = request.LinkedInUrl;
|
||||
creator.Socials.TikTokUrl = request.TikTokUrl;
|
||||
creator.Socials.YoutubeUrl = request.YoutubeUrl;
|
||||
creator.Socials.RedditUrl = request.RedditUrl;
|
||||
creator.Socials.WebsiteUrl = request.WebsiteUrl;
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeTitleRequest(
|
||||
Guid CreatorId,
|
||||
string? Title);
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangeTitleHandler(
|
||||
CreatorsDbContext context)
|
||||
: Endpoint<ChangeTitleRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/creators/{CreatorId}/title");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ChangeTitleRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Creator creator = await context
|
||||
.Creators
|
||||
.SingleAsync(
|
||||
c => c.Id == request.CreatorId,
|
||||
ct);
|
||||
|
||||
creator.Title = request.Title;
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
using Hutopy.Infrastructure.Payments.Stripe.Configuration;
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Stripe;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record CheckStatusStripeResponse(
|
||||
bool IsStripeAccountPresent,
|
||||
bool IsStripeOnboardingComplete,
|
||||
bool IsStripeChargesEnabled,
|
||||
bool IsStripePayoutReady
|
||||
);
|
||||
|
||||
public class CheckStatusStripeIdHandler(
|
||||
IOptionsSnapshot<StripeOptions> stripeOptions,
|
||||
CreatorsDbContext dbContext)
|
||||
: EndpointWithoutRequest<CheckStatusStripeResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/stripe/check-status");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
// 1. Get the creator's information
|
||||
Guid creatorId = HttpContext.User.GetUserId();
|
||||
|
||||
// 2. Get or create the creator
|
||||
Creator? creator = await dbContext.Creators.SingleOrDefaultAsync(c => c.Id == creatorId, ct);
|
||||
if (creator is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. The Creator is not being onboarded
|
||||
if (string.IsNullOrWhiteSpace(creator.StripeAccountId))
|
||||
{
|
||||
await SendErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Update Creator's stripe account information
|
||||
StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey;
|
||||
AccountService accountService = new();
|
||||
Account? account = await accountService.GetAsync(creator.StripeAccountId, cancellationToken: ct);
|
||||
creator.IsStripePayoutReady = account.PayoutsEnabled;
|
||||
creator.IsStripeChargesEnabled = account.ChargesEnabled;
|
||||
creator.IsStripeDetailsSubmitted = account.DetailsSubmitted;
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
// 6. Return the account link URL to the client
|
||||
await SendOkAsync(
|
||||
new CheckStatusStripeResponse(
|
||||
creator.StripeAccountId != null,
|
||||
creator.IsStripeDetailsSubmitted,
|
||||
creator.IsStripeChargesEnabled,
|
||||
creator.IsStripePayoutReady
|
||||
),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
using Hutopy.Infrastructure.Configuration;
|
||||
using Hutopy.Infrastructure.Payments.Stripe.Configuration;
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Stripe;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record ConnectStripeResponse(
|
||||
string Url);
|
||||
|
||||
public class ConnectStripeIdHandler(
|
||||
IOptionsSnapshot<WebsiteOptions> websiteOptions,
|
||||
IOptionsSnapshot<StripeOptions> stripeOptions,
|
||||
CreatorsDbContext dbContext)
|
||||
: EndpointWithoutRequest<ConnectStripeResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/stripe/connect");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
// 1. Get the creator's information
|
||||
Guid creatorId = HttpContext.User.GetUserId();
|
||||
string email = HttpContext.User.GetEmail();
|
||||
|
||||
// 2. Get or create the creator
|
||||
Creator? creator = await dbContext
|
||||
.Creators
|
||||
.SingleOrDefaultAsync(
|
||||
c => c.Id == creatorId,
|
||||
ct);
|
||||
|
||||
if (creator is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Create a Stripe account
|
||||
StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey;
|
||||
AccountService accountService = new();
|
||||
if (string.IsNullOrWhiteSpace(creator.StripeAccountId))
|
||||
{
|
||||
Account? account = await accountService.CreateAsync(
|
||||
new AccountCreateOptions
|
||||
{
|
||||
Type = "express",
|
||||
Capabilities = new AccountCapabilitiesOptions
|
||||
{
|
||||
CardPayments = new AccountCapabilitiesCardPaymentsOptions { Requested = true },
|
||||
Transfers = new AccountCapabilitiesTransfersOptions { Requested = true }
|
||||
},
|
||||
Email = email
|
||||
},
|
||||
cancellationToken: ct);
|
||||
|
||||
// 5. Update the creator's Stripe account ID
|
||||
creator.StripeAccountId = account.Id;
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// 4. Check if the creator already has a Stripe account
|
||||
if (creator is { IsStripeDetailsSubmitted: true, IsStripeChargesEnabled: true, IsStripePayoutReady: true })
|
||||
{
|
||||
await SendErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Create an account link
|
||||
AccountLinkService accountLinkService = new();
|
||||
AccountLink? accountLink = await accountLinkService.CreateAsync(
|
||||
new AccountLinkCreateOptions
|
||||
{
|
||||
Account = creator.StripeAccountId,
|
||||
RefreshUrl = $"{websiteOptions.Value.FrontendBaseUrl}/profile?stripe=retry",
|
||||
ReturnUrl = $"{websiteOptions.Value.FrontendBaseUrl}/profile?stripe=complete",
|
||||
Type = "account_onboarding"
|
||||
},
|
||||
cancellationToken: ct);
|
||||
|
||||
// 6. Return the account link URL to the client
|
||||
await SendOkAsync(new ConnectStripeResponse(accountLink.Url), ct);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record CreateCreatorRequest(
|
||||
Guid SlugReservationId,
|
||||
Guid CreatorId);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class CreateCreatorRequestValidator : Validator<CreateCreatorRequest>
|
||||
{
|
||||
public CreateCreatorRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.SlugReservationId)
|
||||
.NotNull()
|
||||
.NotEmpty()
|
||||
.WithMessage("You should specify a valid SlugReservationId");
|
||||
|
||||
RuleFor(r => r.CreatorId)
|
||||
.NotNull()
|
||||
.NotEmpty()
|
||||
.WithMessage("You should specify a valid CreatorId");
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class CreateCreatorHandler(
|
||||
CreatorsDbContext context)
|
||||
: Endpoint<CreateCreatorRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/creators");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CreateCreatorRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using IDbContextTransaction transaction = await context.Database.BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
Slugs slug = await context
|
||||
.Slugs
|
||||
.SingleAsync(s => s.Id == req.SlugReservationId, ct);
|
||||
|
||||
if (slug.UsedBy is not null
|
||||
|| slug.ReservedUntil < DateTimeOffset.UtcNow
|
||||
|| slug.CreatedBy != User.GetUserId())
|
||||
{
|
||||
await SendErrorsAsync(500, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
slug.UsedBy = req.CreatorId;
|
||||
|
||||
await context.Creators.AddAsync(
|
||||
new Creator
|
||||
{
|
||||
Id = req.CreatorId, CreatedBy = User.GetUserId(), Name = slug.Name, Slug = slug.NormalizedName
|
||||
},
|
||||
ct);
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class GetCreatorByIdRequest
|
||||
{
|
||||
public required Guid CreatorId { get; set; }
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
public sealed class GetCreatorByIdRequestValidator
|
||||
: Validator<GetCreatorByIdRequest>
|
||||
{
|
||||
public GetCreatorByIdRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.CreatorId)
|
||||
.NotNull().WithMessage("You should specify the CreatorId")
|
||||
.NotEmpty().WithMessage("You should specify a valid/not empty CreatorId");
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class GetCreatorByIdHandler(
|
||||
CreatorsDbContext context)
|
||||
: Endpoint<GetCreatorByIdRequest, Creator>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/creators/{CreatorId}");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
GetCreatorByIdRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Creator? creator = await context
|
||||
.Creators
|
||||
.FindAsync(
|
||||
[req.CreatorId],
|
||||
ct);
|
||||
|
||||
if (creator is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await SendAsync(creator, cancellation: ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class GetCreatorBySlugRequest
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public record GetCreatorBySlugResponse
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid CreatedBy { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public Guid? DeletedBy { get; init; }
|
||||
public DateTimeOffset? DeletedAt { get; init; }
|
||||
public bool IsDeleted { get; init; }
|
||||
public bool Verified { get; init; }
|
||||
public bool AcceptDonation { get; init; }
|
||||
public string? BannerUrl { get; init; }
|
||||
public string? PortraitUrl { get; init; }
|
||||
public required string Slug { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public Socials? Socials { get; init; }
|
||||
public Presentation? Presentation { get; init; }
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
public sealed class GetCreatorBySlugRequestValidator
|
||||
: Validator<GetCreatorBySlugRequest>
|
||||
{
|
||||
public GetCreatorBySlugRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.Name)
|
||||
.NotNull().WithMessage("You should specify the Name")
|
||||
.NotEmpty().WithMessage("You should specify a valid/not empty Name");
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class GetCreatorBySlugHandler(
|
||||
CreatorsDbContext context)
|
||||
: Endpoint<GetCreatorBySlugRequest, GetCreatorBySlugResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/creators/@{Name}");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
GetCreatorBySlugRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string creatorName = req.Name.ToLower();
|
||||
|
||||
GetCreatorBySlugResponse? response = await context
|
||||
.Creators
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => EF.Functions.ILike(c.Slug, creatorName))
|
||||
.AsNoTracking()
|
||||
.Select(c => new GetCreatorBySlugResponse
|
||||
{
|
||||
Id = c.Id,
|
||||
CreatedBy = c.CreatedBy,
|
||||
CreatedAt = c.CreatedAt,
|
||||
DeletedBy = c.DeletedBy,
|
||||
DeletedAt = c.DeletedAt,
|
||||
IsDeleted = c.IsDeleted,
|
||||
Verified = c.Verified,
|
||||
BannerUrl = c.BannerUrl,
|
||||
PortraitUrl = c.PortraitUrl,
|
||||
Slug = c.Slug,
|
||||
Name = c.Name,
|
||||
Title = c.Title,
|
||||
AcceptDonation = c.IsStripeChargesEnabled && c.IsStripePayoutReady,
|
||||
Socials = c.Socials,
|
||||
Presentation = c.Presentation
|
||||
})
|
||||
.SingleOrDefaultAsync(ct);
|
||||
|
||||
if (response is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
bool isOwner = User.Identity?.IsAuthenticated == true
|
||||
&& User.GetUserId() == response.CreatedBy;
|
||||
|
||||
if (response.IsDeleted && !isOwner)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class GetCreatorProfileResponse
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid CreatedBy { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public Guid? DeletedBy { get; set; }
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
public bool IsDeleted { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string Slug { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public bool Verified { get; set; }
|
||||
public bool IsStripeAccountPresent { get; set; }
|
||||
public bool IsStripeDetailsSubmitted { get; set; }
|
||||
public bool IsStripePayoutReady { get; set; }
|
||||
public bool IsStripeChargesEnabled { get; set; }
|
||||
public required Presentation Presentation { get; set; }
|
||||
public required Socials Socials { get; set; }
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class GetCreatorProfileHandler(
|
||||
CreatorsDbContext context)
|
||||
: EndpointWithoutRequest<GetCreatorProfileResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/creators/profile");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
GetCreatorProfileResponse? creator = await context
|
||||
.Creators
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => c.Id == HttpContext.User.GetUserId())
|
||||
.AsNoTracking()
|
||||
.Select(c => new GetCreatorProfileResponse
|
||||
{
|
||||
Id = c.Id,
|
||||
CreatedBy = c.CreatedBy,
|
||||
CreatedAt = c.CreatedAt,
|
||||
DeletedBy = c.DeletedBy,
|
||||
DeletedAt = c.DeletedAt,
|
||||
IsDeleted = c.IsDeleted,
|
||||
Name = c.Name,
|
||||
Slug = c.Slug,
|
||||
Title = c.Title,
|
||||
Verified = c.Verified,
|
||||
IsStripeAccountPresent = !string.IsNullOrWhiteSpace(c.StripeAccountId),
|
||||
IsStripeDetailsSubmitted = c.IsStripeDetailsSubmitted,
|
||||
IsStripeChargesEnabled = c.IsStripeChargesEnabled,
|
||||
IsStripePayoutReady = c.IsStripePayoutReady,
|
||||
Presentation = c.Presentation,
|
||||
Socials = c.Socials
|
||||
})
|
||||
.SingleOrDefaultAsync(ct);
|
||||
|
||||
if (creator is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await SendAsync(creator, cancellation: ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record RemoveCreatorRequest(
|
||||
string CreatorSlug);
|
||||
|
||||
[UsedImplicitly]
|
||||
public sealed class RemoveCreatorRequestValidator : Validator<RemoveCreatorRequest>
|
||||
{
|
||||
public RemoveCreatorRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.CreatorSlug)
|
||||
.NotNull()
|
||||
.NotEmpty()
|
||||
.WithMessage("You should specify a valid CreatorSlug");
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class RemoveCreatorHandler(
|
||||
CreatorsDbContext context)
|
||||
: Endpoint<RemoveCreatorRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/creators/@{CreatorSlug}");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
RemoveCreatorRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string creatorSlug = req.CreatorSlug.ToLower();
|
||||
|
||||
Creator? creator = await context
|
||||
.Creators
|
||||
.Where(c => EF.Functions.ILike(c.Slug, creatorSlug))
|
||||
.SingleOrDefaultAsync(ct);
|
||||
|
||||
if (creator is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (creator.CreatedBy != User.GetUserId())
|
||||
{
|
||||
await SendUnauthorizedAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
creator.DeletedAt = DateTimeOffset.UtcNow;
|
||||
creator.DeletedBy = User.GetUserId();
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
using System.Net;
|
||||
using FluentValidation.Results;
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Configuration;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
using Hutopy.Modules.Creators.Services;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record ReserveSlugRequest
|
||||
{
|
||||
public required Guid ReservationId { get; set; }
|
||||
public string Slug { get; set; } = null!;
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class ReserveSlugRequestValidator : Validator<ReserveSlugRequest>
|
||||
{
|
||||
public ReserveSlugRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.Slug)
|
||||
.NotEmpty()
|
||||
.NotNull()
|
||||
.WithMessage("You should specify a valid Slug");
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class ReserveSlug(
|
||||
CreatorsDbContext context,
|
||||
IOptions<CreatorOptions> opts,
|
||||
SlugPurger slugPurger)
|
||||
: Endpoint<ReserveSlugRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/creators/@{Slug}/reserve");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ReserveSlugRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using IDbContextTransaction transaction = await context.Database.BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
// First, purge any expired slugs
|
||||
await slugPurger.PurgeExpiredSlugsAsync(ct);
|
||||
|
||||
Slugs? reservation = await context.Slugs.FirstOrDefaultAsync(
|
||||
s => s.Id == req.ReservationId && s.CreatedBy == User.GetUserId(),
|
||||
ct);
|
||||
|
||||
if (reservation == null)
|
||||
{
|
||||
reservation = new Slugs
|
||||
{
|
||||
Id = req.ReservationId, CreatedBy = User.GetUserId(), CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
context.Slugs.Attach(reservation);
|
||||
context.Entry(reservation).State = EntityState.Added;
|
||||
}
|
||||
|
||||
reservation.Name = req.Slug;
|
||||
reservation.ReservedUntil = DateTimeOffset.UtcNow + opts.Value.SlugReservationDuration;
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
await SendOkAsync(new { Message = "Slug reserved." }, ct);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
|
||||
Logger.LogError("Transaction failed: {Message}", e.Message);
|
||||
|
||||
if (e.InnerException is PostgresException innerException)
|
||||
{
|
||||
if (innerException.ConstraintName == "IX_Slugs_NormalizedName")
|
||||
{
|
||||
await SendResultAsync(new ProblemDetails(
|
||||
[
|
||||
new ValidationFailure(nameof(Slugs.Name),
|
||||
"The name is already taken.")
|
||||
],
|
||||
(int)HttpStatusCode.Conflict));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await SendResultAsync(new ProblemDetails(
|
||||
[
|
||||
new ValidationFailure(nameof(Slugs.Name),
|
||||
e.Message)
|
||||
],
|
||||
(int)HttpStatusCode.Conflict));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record RestoreCreatorRequest(
|
||||
string CreatorSlug);
|
||||
|
||||
[UsedImplicitly]
|
||||
public sealed class RestoreCreatorRequestValidator : Validator<RestoreCreatorRequest>
|
||||
{
|
||||
public RestoreCreatorRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.CreatorSlug)
|
||||
.NotNull()
|
||||
.NotEmpty()
|
||||
.WithMessage("You should specify a valid CreatorSlug");
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class RestoreCreatorHandler(
|
||||
CreatorsDbContext context)
|
||||
: Endpoint<RestoreCreatorRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/creators/@{CreatorSlug}/restore");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
RestoreCreatorRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string creatorSlug = req.CreatorSlug.ToLower();
|
||||
|
||||
Creator? creator = await context
|
||||
.Creators
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => EF.Functions.ILike(c.Slug, creatorSlug))
|
||||
.SingleOrDefaultAsync(ct);
|
||||
|
||||
if (creator is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (creator.CreatedBy != User.GetUserId())
|
||||
{
|
||||
await SendUnauthorizedAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
creator.DeletedAt = null;
|
||||
creator.DeletedBy = null;
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public class RemoveStripeHandler(
|
||||
CreatorsDbContext dbContext)
|
||||
: EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/stripe");
|
||||
Options(o => o.WithTags("Creators"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
// 1. Get the creator's ID from the authenticated user
|
||||
Guid creatorId = HttpContext.User.GetUserId();
|
||||
|
||||
// 2. Retrieve the creator from the database
|
||||
Creator? creator = await dbContext
|
||||
.Creators
|
||||
.SingleOrDefaultAsync(
|
||||
c => c.Id == creatorId,
|
||||
ct);
|
||||
|
||||
// 3. If the creator doesn't exist or has no Stripe account linked, return 404
|
||||
if (creator is null || string.IsNullOrWhiteSpace(creator.StripeAccountId))
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Remove Stripe configuration
|
||||
creator.StripeAccountId = null;
|
||||
creator.IsStripeDetailsSubmitted = false;
|
||||
creator.IsStripeChargesEnabled = false;
|
||||
creator.IsStripePayoutReady = false;
|
||||
|
||||
// 5. Persist changes
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
// 6. Respond with success
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Modules.Creators.Migrations
|
||||
{
|
||||
[DbContext(typeof(CreatorsDbContext))]
|
||||
[Migration("20250609203815_Initial")]
|
||||
partial class Initial
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("Creators")
|
||||
.HasAnnotation("ProductVersion", "9.0.3")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Creators.Data.Creator", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("AcceptDonation")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("BannerUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("boolean")
|
||||
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
|
||||
|
||||
b.Property<bool>("IsStripeChargesEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsStripeOnboardingComplete")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsStripePayoutReady")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PortraitUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("StripeAccountId")
|
||||
.HasMaxLength(21)
|
||||
.HasColumnType("character varying(21)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("Verified")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Creators", "Creators");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Creators.Data.Slugs", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComputedColumnSql("LOWER(\"Name\")", true);
|
||||
|
||||
b.Property<DateTimeOffset>("ReservedUntil")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("UsedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Slugs", "Creators");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Creators.Data.Creator", b =>
|
||||
{
|
||||
b.OwnsOne("Hutopy.Modules.Creators.Data.Presentation", "Presentation", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("CreatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b1.Property<string>("PhoneNumber")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b1.Property<string>("VideoUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.HasKey("CreatorId");
|
||||
|
||||
b1.ToTable("Presentation", "Creators");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("CreatorId");
|
||||
});
|
||||
|
||||
b.OwnsOne("Hutopy.Modules.Creators.Data.Socials", "Socials", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("CreatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("FacebookUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("InstagramUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("LinkedInUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("RedditUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("TikTokUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("WebsiteUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("XUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("YoutubeUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.HasKey("CreatorId");
|
||||
|
||||
b1.ToTable("Socials", "Creators");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("CreatorId");
|
||||
});
|
||||
|
||||
b.Navigation("Presentation")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Socials")
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Modules.Creators.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Initial : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "Creators");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Creators",
|
||||
schema: "Creators",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
DeletedBy = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "boolean", nullable: false, computedColumnSql: "\"DeletedAt\" IS NOT NULL", stored: true),
|
||||
BannerUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
PortraitUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
Verified = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
Slug = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
Title = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
StripeAccountId = table.Column<string>(type: "character varying(21)", maxLength: 21, nullable: true),
|
||||
IsStripeOnboardingComplete = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsStripePayoutReady = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsStripeChargesEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
AcceptDonation = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Creators", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Slugs",
|
||||
schema: "Creators",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UsedBy = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
Name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
NormalizedName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, computedColumnSql: "LOWER(\"Name\")", stored: true),
|
||||
ReservedUntil = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Slugs", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Presentation",
|
||||
schema: "Creators",
|
||||
columns: table => new
|
||||
{
|
||||
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Description = table.Column<string>(type: "text", nullable: false),
|
||||
VideoUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
PhoneNumber = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Presentation", x => x.CreatorId);
|
||||
table.ForeignKey(
|
||||
name: "FK_Presentation_Creators_CreatorId",
|
||||
column: x => x.CreatorId,
|
||||
principalSchema: "Creators",
|
||||
principalTable: "Creators",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Socials",
|
||||
schema: "Creators",
|
||||
columns: table => new
|
||||
{
|
||||
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FacebookUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
InstagramUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
XUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
LinkedInUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
TikTokUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
YoutubeUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
RedditUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
WebsiteUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Socials", x => x.CreatorId);
|
||||
table.ForeignKey(
|
||||
name: "FK_Socials_Creators_CreatorId",
|
||||
column: x => x.CreatorId,
|
||||
principalSchema: "Creators",
|
||||
principalTable: "Creators",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Slugs_NormalizedName",
|
||||
schema: "Creators",
|
||||
table: "Slugs",
|
||||
column: "NormalizedName",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Presentation",
|
||||
schema: "Creators");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Slugs",
|
||||
schema: "Creators");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Socials",
|
||||
schema: "Creators");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Creators",
|
||||
schema: "Creators");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Modules.Creators.Migrations
|
||||
{
|
||||
[DbContext(typeof(CreatorsDbContext))]
|
||||
[Migration("20250610200446_AddStripe")]
|
||||
partial class AddStripe
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("Creators")
|
||||
.HasAnnotation("ProductVersion", "9.0.3")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Creators.Data.Creator", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BannerUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("boolean")
|
||||
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
|
||||
|
||||
b.Property<bool>("IsStripeChargesEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsStripeDetailsSubmitted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsStripePayoutReady")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PortraitUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("StripeAccountId")
|
||||
.HasMaxLength(21)
|
||||
.HasColumnType("character varying(21)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("Verified")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Creators", "Creators");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Creators.Data.Slugs", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComputedColumnSql("LOWER(\"Name\")", true);
|
||||
|
||||
b.Property<DateTimeOffset>("ReservedUntil")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("UsedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Slugs", "Creators");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Creators.Data.Creator", b =>
|
||||
{
|
||||
b.OwnsOne("Hutopy.Modules.Creators.Data.Presentation", "Presentation", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("CreatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b1.Property<string>("PhoneNumber")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b1.Property<string>("VideoUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.HasKey("CreatorId");
|
||||
|
||||
b1.ToTable("Presentation", "Creators");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("CreatorId");
|
||||
});
|
||||
|
||||
b.OwnsOne("Hutopy.Modules.Creators.Data.Socials", "Socials", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("CreatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("FacebookUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("InstagramUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("LinkedInUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("RedditUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("TikTokUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("WebsiteUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("XUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("YoutubeUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.HasKey("CreatorId");
|
||||
|
||||
b1.ToTable("Socials", "Creators");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("CreatorId");
|
||||
});
|
||||
|
||||
b.Navigation("Presentation")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Socials")
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Modules.Creators.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddStripe : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AcceptDonation",
|
||||
schema: "Creators",
|
||||
table: "Creators");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "IsStripeOnboardingComplete",
|
||||
schema: "Creators",
|
||||
table: "Creators",
|
||||
newName: "IsStripeDetailsSubmitted");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "IsStripeDetailsSubmitted",
|
||||
schema: "Creators",
|
||||
table: "Creators",
|
||||
newName: "IsStripeOnboardingComplete");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AcceptDonation",
|
||||
schema: "Creators",
|
||||
table: "Creators",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Modules.Creators.Migrations
|
||||
{
|
||||
[DbContext(typeof(CreatorsDbContext))]
|
||||
partial class CreatorsDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("Creators")
|
||||
.HasAnnotation("ProductVersion", "9.0.3")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Creators.Data.Creator", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BannerUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("boolean")
|
||||
.HasComputedColumnSql("\"DeletedAt\" IS NOT NULL", true);
|
||||
|
||||
b.Property<bool>("IsStripeChargesEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsStripeDetailsSubmitted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsStripePayoutReady")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PortraitUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("StripeAccountId")
|
||||
.HasMaxLength(21)
|
||||
.HasColumnType("character varying(21)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("Verified")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Creators", "Creators");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Creators.Data.Slugs", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComputedColumnSql("LOWER(\"Name\")", true);
|
||||
|
||||
b.Property<DateTimeOffset>("ReservedUntil")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("UsedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Slugs", "Creators");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Creators.Data.Creator", b =>
|
||||
{
|
||||
b.OwnsOne("Hutopy.Modules.Creators.Data.Presentation", "Presentation", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("CreatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b1.Property<string>("PhoneNumber")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b1.Property<string>("VideoUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.HasKey("CreatorId");
|
||||
|
||||
b1.ToTable("Presentation", "Creators");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("CreatorId");
|
||||
});
|
||||
|
||||
b.OwnsOne("Hutopy.Modules.Creators.Data.Socials", "Socials", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("CreatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("FacebookUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("InstagramUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("LinkedInUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("RedditUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("TikTokUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("WebsiteUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("XUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.Property<string>("YoutubeUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b1.HasKey("CreatorId");
|
||||
|
||||
b1.ToTable("Socials", "Creators");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("CreatorId");
|
||||
});
|
||||
|
||||
b.Navigation("Presentation")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Socials")
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
using Hutopy.Modules.Creators.Contracts;
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Services;
|
||||
|
||||
public sealed class CreatorLookup(
|
||||
CreatorsDbContext context)
|
||||
: ICreatorLookup
|
||||
{
|
||||
public async Task<CreatorReference?> GetCreatorAsync(Guid creatorId, CancellationToken cancellationToken)
|
||||
{
|
||||
Creator? creator = await context
|
||||
.Creators
|
||||
.FirstOrDefaultAsync(c => c.Id == creatorId, cancellationToken);
|
||||
|
||||
return creator is null
|
||||
? null
|
||||
: new CreatorReference(
|
||||
creator.Id,
|
||||
creator.Name,
|
||||
creator.PortraitUrl,
|
||||
creator.IsStripeDetailsSubmitted,
|
||||
creator.IsStripeChargesEnabled,
|
||||
creator.StripeAccountId);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
using Hutopy.Modules.Creators.Data;
|
||||
|
||||
namespace Hutopy.Modules.Creators.Services;
|
||||
|
||||
public class SlugPurger(CreatorsDbContext context)
|
||||
{
|
||||
private static readonly SemaphoreSlim Semaphore = new(1, 1);
|
||||
private static DateTimeOffset s_lastPurgeTime = DateTimeOffset.MinValue;
|
||||
private static readonly TimeSpan MinTimeBetweenPurges = TimeSpan.FromSeconds(10);
|
||||
|
||||
public async Task PurgeExpiredSlugsAsync(CancellationToken ct)
|
||||
{
|
||||
// Try to acquire the semaphore
|
||||
if (!await Semaphore.WaitAsync(0, ct))
|
||||
{
|
||||
// Another purge operation is in progress, skip this one
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
if (now - s_lastPurgeTime < MinTimeBetweenPurges)
|
||||
{
|
||||
// Not enough time has passed since the last purge
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete expired slugs that are not in use
|
||||
await context
|
||||
.Slugs
|
||||
.Where(s => s.ReservedUntil < now && s.UsedBy == null)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
|
||||
// Update the last purge time regardless of whether we found expired slugs or not
|
||||
s_lastPurgeTime = now;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Semaphore.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace Hutopy.Modules.Identity.Contracts;
|
||||
|
||||
public static class KnownRoles
|
||||
{
|
||||
public const string Administrator = nameof(Administrator);
|
||||
public const string Creator = nameof(Creator);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
|
||||
namespace Hutopy.Modules.Identity.Data;
|
||||
|
||||
public class IdentityDbContext(
|
||||
DbContextOptions<IdentityDbContext> options)
|
||||
: IdentityDbContext<User, Role, Guid>(options)
|
||||
{
|
||||
public const string SchemaName = "Identity";
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder
|
||||
modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.HasDefaultSchema(SchemaName);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
using Hutopy.Modules.Identity.Configuration;
|
||||
using Hutopy.Modules.Identity.Contracts;
|
||||
using Hutopy.Modules.Identity.Data;
|
||||
using Hutopy.Modules.Identity.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Hutopy.Modules.Identity;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddIdentityModule(
|
||||
this WebApplicationBuilder builder,
|
||||
Action<DbContextOptionsBuilder>? configureAction = null)
|
||||
{
|
||||
builder.Services.AddDbContext<IdentityDbContext>(configureAction);
|
||||
|
||||
builder.Services.Configure<JwtOptions>(
|
||||
builder.Configuration.GetRequiredSection(JwtOptions.SectionName));
|
||||
|
||||
builder.Services.AddAuthentication()
|
||||
.AddBearerToken(IdentityConstants.BearerScheme);
|
||||
|
||||
builder.Services.AddAuthorizationBuilder();
|
||||
|
||||
builder.Services
|
||||
.AddIdentityCore<User>()
|
||||
.AddUserManager<UserManager>()
|
||||
.AddRoles<Role>()
|
||||
.AddEntityFrameworkStores<IdentityDbContext>()
|
||||
.AddApiEndpoints()
|
||||
.AddDefaultTokenProviders();
|
||||
|
||||
// Singleton services
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
|
||||
// Scoped services
|
||||
builder.Services.AddScoped<IdentityService>();
|
||||
builder.Services.AddScoped<EmailVerificationService>();
|
||||
builder.Services.AddScoped<IUserLookup, UserLookup>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static async Task<IApplicationBuilder> UseIdentityModuleAsync(
|
||||
this IApplicationBuilder app,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IServiceScopeFactory scopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>();
|
||||
using IServiceScope scope = scopeFactory.CreateScope();
|
||||
await using IdentityDbContext context = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
|
||||
RoleManager<Role> roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<Role>>();
|
||||
await TrySeedAsync(roleManager);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task TrySeedAsync(RoleManager<Role> roleManager)
|
||||
{
|
||||
Role administratorRole = new(KnownRoles.Administrator);
|
||||
if (roleManager.Roles.All(r => r.Name != administratorRole.Name))
|
||||
{
|
||||
await roleManager.CreateAsync(administratorRole);
|
||||
}
|
||||
|
||||
Role roleCreator = new(KnownRoles.Creator);
|
||||
if (roleManager.Roles.All(r => r.Name != roleCreator.Name))
|
||||
{
|
||||
await roleManager.CreateAsync(roleCreator);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using Hutopy.Modules.Identity.Data;
|
||||
using Hutopy.Modules.Identity.Models;
|
||||
|
||||
namespace Hutopy.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public class GetCurrentUserQueryHandler(
|
||||
IdentityService identityService)
|
||||
: EndpointWithoutRequest<UserDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/users/profile");
|
||||
Options(o => o.WithTags("Memberships"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
UserModel? userModel = await identityService.GetCurrentUserAsync();
|
||||
|
||||
if (userModel is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
IList<string> roles = await identityService.GetCurrentUserRolesAsync();
|
||||
|
||||
await SendOkAsync(
|
||||
new UserDto
|
||||
{
|
||||
Id = userModel.Id,
|
||||
Alias = userModel.Alias,
|
||||
PortraitUrl = userModel.PortraitUrl,
|
||||
Firstname = userModel.Firstname,
|
||||
Lastname = userModel.Lastname,
|
||||
Username = userModel.Username,
|
||||
PhoneNumber = userModel.PhoneNumber,
|
||||
Email = userModel.Email,
|
||||
BirthDate = userModel.BirthDate,
|
||||
Address = userModel.Address,
|
||||
UserRoles = roles
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Hutopy.Modules.Identity.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Modules.Identity.Migrations
|
||||
{
|
||||
[DbContext(typeof(IdentityDbContext))]
|
||||
[Migration("20250609203622_Initial")]
|
||||
partial class Initial
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("Identity")
|
||||
.HasAnnotation("ProductVersion", "9.0.3")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Identity.Data.Role", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", "Identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Identity.Data.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Address")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("Alias")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<DateTime?>("BirthDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("FacebookId")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("Firstname")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("GoogleId")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("Lastname")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("PortraitUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.HasMaxLength(44)
|
||||
.HasColumnType("character varying(44)");
|
||||
|
||||
b.Property<DateTime>("RefreshTokenExpiryTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.ToTable("AspNetUsers", "Identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", "Identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", "Identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", "Identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", "Identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", "Identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Modules.Identity.Data.Role", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Modules.Identity.Data.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Modules.Identity.Data.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Modules.Identity.Data.Role", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Hutopy.Modules.Identity.Data.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Modules.Identity.Data.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Modules.Identity.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Initial : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "Identity");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetRoles",
|
||||
schema: "Identity",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
NormalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUsers",
|
||||
schema: "Identity",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Alias = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
Firstname = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
Lastname = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
BirthDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
Address = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
PortraitUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
GoogleId = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
FacebookId = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
RefreshToken = table.Column<string>(type: "character varying(44)", maxLength: 44, nullable: true),
|
||||
RefreshTokenExpiryTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
NormalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
NormalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
|
||||
PasswordHash = table.Column<string>(type: "text", nullable: true),
|
||||
SecurityStamp = table.Column<string>(type: "text", nullable: true),
|
||||
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
|
||||
PhoneNumber = table.Column<string>(type: "text", nullable: true),
|
||||
PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
|
||||
TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
AccessFailedCount = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetRoleClaims",
|
||||
schema: "Identity",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
RoleId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ClaimType = table.Column<string>(type: "text", nullable: true),
|
||||
ClaimValue = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
|
||||
column: x => x.RoleId,
|
||||
principalSchema: "Identity",
|
||||
principalTable: "AspNetRoles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserClaims",
|
||||
schema: "Identity",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ClaimType = table.Column<string>(type: "text", nullable: true),
|
||||
ClaimValue = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalSchema: "Identity",
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserLogins",
|
||||
schema: "Identity",
|
||||
columns: table => new
|
||||
{
|
||||
LoginProvider = table.Column<string>(type: "text", nullable: false),
|
||||
ProviderKey = table.Column<string>(type: "text", nullable: false),
|
||||
ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalSchema: "Identity",
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserRoles",
|
||||
schema: "Identity",
|
||||
columns: table => new
|
||||
{
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
RoleId = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
|
||||
column: x => x.RoleId,
|
||||
principalSchema: "Identity",
|
||||
principalTable: "AspNetRoles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalSchema: "Identity",
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserTokens",
|
||||
schema: "Identity",
|
||||
columns: table => new
|
||||
{
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
LoginProvider = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Value = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalSchema: "Identity",
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetRoleClaims_RoleId",
|
||||
schema: "Identity",
|
||||
table: "AspNetRoleClaims",
|
||||
column: "RoleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "RoleNameIndex",
|
||||
schema: "Identity",
|
||||
table: "AspNetRoles",
|
||||
column: "NormalizedName",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetUserClaims_UserId",
|
||||
schema: "Identity",
|
||||
table: "AspNetUserClaims",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetUserLogins_UserId",
|
||||
schema: "Identity",
|
||||
table: "AspNetUserLogins",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetUserRoles_RoleId",
|
||||
schema: "Identity",
|
||||
table: "AspNetUserRoles",
|
||||
column: "RoleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "EmailIndex",
|
||||
schema: "Identity",
|
||||
table: "AspNetUsers",
|
||||
column: "NormalizedEmail");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UserNameIndex",
|
||||
schema: "Identity",
|
||||
table: "AspNetUsers",
|
||||
column: "NormalizedUserName",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetRoleClaims",
|
||||
schema: "Identity");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUserClaims",
|
||||
schema: "Identity");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUserLogins",
|
||||
schema: "Identity");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUserRoles",
|
||||
schema: "Identity");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUserTokens",
|
||||
schema: "Identity");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetRoles",
|
||||
schema: "Identity");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUsers",
|
||||
schema: "Identity");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Hutopy.Modules.Identity.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Modules.Identity.Migrations
|
||||
{
|
||||
[DbContext(typeof(IdentityDbContext))]
|
||||
partial class IdentityDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("Identity")
|
||||
.HasAnnotation("ProductVersion", "9.0.3")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Identity.Data.Role", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", "Identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Identity.Data.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Address")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("Alias")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<DateTime?>("BirthDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("FacebookId")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("Firstname")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("GoogleId")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("Lastname")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("PortraitUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.HasMaxLength(44)
|
||||
.HasColumnType("character varying(44)");
|
||||
|
||||
b.Property<DateTime>("RefreshTokenExpiryTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.ToTable("AspNetUsers", "Identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", "Identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", "Identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", "Identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", "Identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", "Identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Modules.Identity.Data.Role", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Modules.Identity.Data.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Modules.Identity.Data.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Modules.Identity.Data.Role", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Hutopy.Modules.Identity.Data.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Modules.Identity.Data.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Hutopy.Modules.Memberships.Contracts;
|
||||
|
||||
public interface IMembershipCancellationProcessor
|
||||
{
|
||||
Task<DateTimeOffset?> CancelAsync(string subscriptionId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
namespace Hutopy.Modules.Memberships.Contracts;
|
||||
|
||||
public interface IMembershipNotifier
|
||||
{
|
||||
Task NotifyCheckoutSessionCompleted(string stripeSessionId, string stripeSubscriptionId,
|
||||
string userId,
|
||||
string creatorId,
|
||||
string tierId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task NotifyPaymentSucceedAsync(
|
||||
string stripeSubscriptionId,
|
||||
string hostedInvoiceUrl,
|
||||
decimal amount,
|
||||
string currency,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task NotifySubscriptionUpdatedAsync(
|
||||
string subscriptionId,
|
||||
DateTimeOffset? endDate,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task NotifySubscriptionDeletedAsync(
|
||||
string subscriptionId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using Hutopy.Modules.Creators.Contracts;
|
||||
|
||||
namespace Hutopy.Modules.Memberships.Contracts;
|
||||
|
||||
public interface IMembershipPaymentProcessor
|
||||
{
|
||||
Task<MembershipCheckoutSession> CreateCheckoutSessionAsync(
|
||||
Guid userId,
|
||||
CreatorReference creatorReference,
|
||||
Guid tierId,
|
||||
string priceId,
|
||||
string successUrl,
|
||||
string cancelUrl);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace Hutopy.Modules.Memberships.Contracts;
|
||||
|
||||
public interface IMembershipTierProcessor
|
||||
{
|
||||
Task<string> CreateAsync(
|
||||
Guid creatorId,
|
||||
Guid tierId,
|
||||
string productName,
|
||||
string currencyCode,
|
||||
decimal amount);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Hutopy.Modules.Memberships.Contracts;
|
||||
|
||||
[PublicAPI]
|
||||
public record MembershipCheckoutSession(
|
||||
string Id,
|
||||
string Url);
|
||||
@@ -1,20 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Hutopy.Modules.Memberships.Data;
|
||||
|
||||
public class Membership
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public Guid CreatorId { get; set; }
|
||||
public Guid TierId { get; set; }
|
||||
public MembershipTier? MembershipTier { get; set; }
|
||||
public MembershipState State { get; set; }
|
||||
public DateTimeOffset? StartDate { get; set; }
|
||||
public DateTimeOffset? EndDate { get; set; }
|
||||
public bool IsActive => EndDate == null || EndDate > DateTimeOffset.UtcNow;
|
||||
[MaxLength(256)] public string? StripeSubscriptionId { get; set; }
|
||||
|
||||
public ICollection<Payment> Payments { get; set; } = [];
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Hutopy.Modules.Memberships.Data;
|
||||
|
||||
public enum MembershipState
|
||||
{
|
||||
Pending,
|
||||
Active,
|
||||
Inactive
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Hutopy.Common.Domain;
|
||||
|
||||
namespace Hutopy.Modules.Memberships.Data;
|
||||
|
||||
public class MembershipTier : Entity
|
||||
{
|
||||
public Guid CreatorId { get; set; }
|
||||
[MaxLength(128)] public string Name { get; set; } = null!;
|
||||
[MaxLength(4096)] public string Description { get; set; } = null!;
|
||||
public decimal Price { get; set; }
|
||||
[MaxLength(128)] public string CurrencyCode { get; set; } = null!;
|
||||
[MaxLength(128)] public string StripeProductId { get; set; } = null!;
|
||||
[MaxLength(128)] public string StripePriceId { get; set; } = null!;
|
||||
|
||||
public ICollection<Membership> Subscriptions { get; set; } = [];
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
namespace Hutopy.Modules.Memberships.Data;
|
||||
|
||||
public sealed class MembershipsDbContext(
|
||||
DbContextOptions<MembershipsDbContext> options)
|
||||
: DbContext(options)
|
||||
{
|
||||
public const string SchemaName = "Memberships";
|
||||
|
||||
public DbSet<MembershipTier> MembershipTiers => Set<MembershipTier>();
|
||||
public DbSet<Membership> Memberships => Set<Membership>();
|
||||
public DbSet<Payment> Payments => Set<Payment>();
|
||||
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema(SchemaName);
|
||||
|
||||
modelBuilder
|
||||
.Entity<MembershipTier>()
|
||||
.Property(c => c.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
modelBuilder
|
||||
.Entity<Membership>()
|
||||
.Property(c => c.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
modelBuilder
|
||||
.Entity<Payment>()
|
||||
.Property(c => c.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Hutopy.Modules.Memberships.Data;
|
||||
|
||||
public class Payment
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
[MaxLength(8)] public required string Currency { get; set; }
|
||||
[MaxLength(2048)] public required string InvoiceUrl { get; set; }
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
using Hutopy.Modules.Memberships.Contracts;
|
||||
using Hutopy.Modules.Memberships.Data;
|
||||
using Hutopy.Modules.Memberships.Services;
|
||||
|
||||
namespace Hutopy.Modules.Memberships;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddMembershipModule(
|
||||
this WebApplicationBuilder builder,
|
||||
Action<DbContextOptionsBuilder>? configureAction = null)
|
||||
{
|
||||
builder.Services.AddDbContext<MembershipsDbContext>(configureAction);
|
||||
|
||||
builder.Services.AddTransient<IMembershipNotifier, MembershipNotifier>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
|
||||
public static async Task<IApplicationBuilder> UseMembershipModuleAsync(
|
||||
this IApplicationBuilder app,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IServiceScopeFactory scopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>();
|
||||
using IServiceScope scope = scopeFactory.CreateScope();
|
||||
await using MembershipsDbContext context = scope.ServiceProvider.GetRequiredService<MembershipsDbContext>();
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using Hutopy.Modules.Memberships.Contracts;
|
||||
using Hutopy.Modules.Memberships.Data;
|
||||
|
||||
namespace Hutopy.Modules.Memberships.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public class CancelMembershipRequest
|
||||
{
|
||||
public Guid SubscriptionId { get; set; }
|
||||
}
|
||||
|
||||
public class CancelMembershipHandler(
|
||||
MembershipsDbContext dbContext,
|
||||
IMembershipCancellationProcessor cancellationProcessor)
|
||||
: Endpoint<CancelMembershipRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/memberships");
|
||||
Options(o => o.WithTags("Memberships"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CancelMembershipRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Membership? subscription = await dbContext
|
||||
.Memberships
|
||||
.FindAsync(
|
||||
[req.SubscriptionId],
|
||||
ct);
|
||||
|
||||
if (subscription is not { EndDate: null }
|
||||
|| subscription.StripeSubscriptionId is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel Stripe subscription
|
||||
await cancellationProcessor.CancelAsync(subscription.StripeSubscriptionId, ct);
|
||||
|
||||
// Update subscription in the system
|
||||
subscription.EndDate = DateTime.UtcNow;
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(subscription.Id, ct);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
using Hutopy.Modules.Memberships.Contracts;
|
||||
using Hutopy.Modules.Memberships.Data;
|
||||
|
||||
namespace Hutopy.Modules.Memberships.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record struct CreateMembershipTierRequest(
|
||||
Guid CreatorId,
|
||||
string Name,
|
||||
string Description,
|
||||
decimal Price,
|
||||
string Currency = "CAD");
|
||||
|
||||
[PublicAPI]
|
||||
public class CreateMembershipTierEndpoint(
|
||||
MembershipsDbContext dbContext,
|
||||
IMembershipTierProcessor membershipTierProcessor)
|
||||
: Endpoint<CreateMembershipTierRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/memberships/tiers");
|
||||
Options(o => o.WithTags("Memberships"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CreateMembershipTierRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Guid tierId = Guid.CreateVersion7();
|
||||
|
||||
string productId = await membershipTierProcessor.CreateAsync(
|
||||
req.CreatorId,
|
||||
tierId,
|
||||
req.Name,
|
||||
req.Currency,
|
||||
req.Price);
|
||||
|
||||
// Record the new Tier
|
||||
MembershipTier tier = new()
|
||||
{
|
||||
Id = tierId,
|
||||
CreatorId = req.CreatorId,
|
||||
Price = req.Price,
|
||||
Name = req.Name,
|
||||
Description = req.Description,
|
||||
StripeProductId = productId
|
||||
};
|
||||
|
||||
dbContext.MembershipTiers.Add(tier);
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(tier, ct);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Contracts;
|
||||
using Hutopy.Modules.Memberships.Data;
|
||||
|
||||
namespace Hutopy.Modules.Memberships.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record struct GetActiveMembershipsResponse(
|
||||
Guid Id,
|
||||
Guid CreatorId,
|
||||
string CreatorName,
|
||||
string CreatorPortraitUrl,
|
||||
DateTimeOffset? StartDate,
|
||||
DateTimeOffset? EndDate);
|
||||
|
||||
[PublicAPI]
|
||||
public class GetActiveMembershipsHandler(
|
||||
ICreatorLookup creatorLookup,
|
||||
MembershipsDbContext dbContext)
|
||||
: EndpointWithoutRequest<IEnumerable<GetActiveMembershipsResponse>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/memberships/active");
|
||||
Options(o => o.WithTags("Memberships"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
List<Membership> subscriptions = await dbContext
|
||||
.Memberships
|
||||
.Where(subscription => subscription.UserId == User.GetUserId())
|
||||
.Where(subscription => subscription.State == MembershipState.Active)
|
||||
.ToListAsync(ct);
|
||||
|
||||
GetActiveMembershipsResponse[] result = await Task.WhenAll(
|
||||
subscriptions.Select(async subscription =>
|
||||
{
|
||||
CreatorReference? creator = await creatorLookup.GetCreatorAsync(subscription.CreatorId, ct);
|
||||
|
||||
return new GetActiveMembershipsResponse(
|
||||
subscription.Id,
|
||||
subscription.CreatorId,
|
||||
creator?.Name ?? "Unknown Creator",
|
||||
creator?.PortraitUrl ?? string.Empty,
|
||||
subscription.StartDate,
|
||||
subscription.EndDate);
|
||||
}));
|
||||
|
||||
|
||||
await SendOkAsync(result, ct);
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
using Hutopy.Modules.Memberships.Data;
|
||||
|
||||
namespace Hutopy.Modules.Memberships.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record GetMembershipTiersRequest
|
||||
{
|
||||
public Guid CreatorId { get; set; }
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public record struct TierModel(
|
||||
Guid Id,
|
||||
DateTimeOffset CreatedAt,
|
||||
string Name,
|
||||
string Description,
|
||||
decimal Price,
|
||||
string CurrencyCode,
|
||||
string StripeProductId);
|
||||
|
||||
[PublicAPI]
|
||||
public class GetMembershipTiersEndpoint(
|
||||
MembershipsDbContext dbContext)
|
||||
: Endpoint<GetMembershipTiersRequest, List<TierModel>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/memberships/tiers/{CreatorId:guid}");
|
||||
Options(o => o.WithTags("Memberships"));
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
GetMembershipTiersRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
List<TierModel> tiers = await dbContext
|
||||
.MembershipTiers
|
||||
.Where(tier => tier.CreatorId == req.CreatorId)
|
||||
.Select(tier => new TierModel(
|
||||
tier.Id,
|
||||
tier.CreatedAt,
|
||||
tier.Name,
|
||||
tier.Description,
|
||||
tier.Price,
|
||||
tier.CurrencyCode,
|
||||
tier.StripeProductId))
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(tiers, ct);
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using Hutopy.Infrastructure.Payments.Stripe.Configuration;
|
||||
using Hutopy.Modules.Memberships.Contracts;
|
||||
using Hutopy.Modules.Tipping.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
|
||||
namespace Hutopy.Modules.Memberships.Handlers;
|
||||
|
||||
internal class StripeWebhookEndpoint(
|
||||
ITipPaymentNotifier tipPaymentNotifier,
|
||||
IMembershipNotifier membershipNotifier,
|
||||
IOptions<StripeOptions> stripeOptions)
|
||||
: EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/stripe");
|
||||
AllowAnonymous();
|
||||
Options(o => o.WithTags("Webhooks"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var signatureHeader = HttpContext.Request.Headers["Stripe-Signature"];
|
||||
using StreamReader streamReader = new(HttpContext.Request.Body);
|
||||
|
||||
var json = await streamReader.ReadToEndAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var stripeEvent = EventUtility.ConstructEvent(json, signatureHeader, stripeOptions.Value.WebhookSecret);
|
||||
|
||||
var stripeSession = stripeEvent.Data.Object as Session;
|
||||
var stripeSubscription = stripeEvent.Data.Object as Subscription;
|
||||
|
||||
switch (stripeEvent.Type)
|
||||
{
|
||||
case "checkout.session.completed":
|
||||
Debug.Assert(stripeSession != null);
|
||||
switch (stripeSession.Mode)
|
||||
{
|
||||
// Check if this is a one-time tip
|
||||
case "payment" when stripeSession is { PaymentIntentId: not null, PaymentStatus: "paid" }:
|
||||
// Get the customer email from the appropriate place
|
||||
var customerEmail = stripeSession.CustomerDetails?.Email ??
|
||||
stripeSession.Customer?.Email ??
|
||||
"";
|
||||
|
||||
StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey;
|
||||
var paymentIntentService = new PaymentIntentService();
|
||||
var paymentIntent = await paymentIntentService
|
||||
.GetAsync(
|
||||
stripeSession.PaymentIntentId,
|
||||
new PaymentIntentGetOptions { Expand = ["latest_charge"] },
|
||||
cancellationToken: ct)
|
||||
.ConfigureAwait(false);
|
||||
var receiptUrl = paymentIntent.LatestCharge.ReceiptUrl;
|
||||
var receiptUri = new Uri(receiptUrl);
|
||||
|
||||
// Get the receipt URL, preferring the one directly on the charge if available
|
||||
await tipPaymentNotifier
|
||||
.NotifyPaymentSucceedAsync(
|
||||
stripeSession.Id,
|
||||
receiptUri,
|
||||
customerEmail,
|
||||
ct)
|
||||
.ConfigureAwait(false);
|
||||
break;
|
||||
|
||||
// Check if this is a subscription
|
||||
case "subscription" when stripeSession.SubscriptionId != null:
|
||||
await membershipNotifier
|
||||
.NotifyPaymentSucceedAsync(
|
||||
stripeSession.SubscriptionId,
|
||||
stripeSession.Invoice.HostedInvoiceUrl,
|
||||
stripeSession.Invoice.Total,
|
||||
stripeSession.Invoice.Currency,
|
||||
ct)
|
||||
.ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
case "invoice.payment_succeeded":
|
||||
var invoice = stripeEvent.Data.Object as Invoice;
|
||||
Debug.Assert(invoice != null);
|
||||
Debug.Assert(invoice.Subscription != null);
|
||||
await membershipNotifier
|
||||
.NotifyPaymentSucceedAsync(
|
||||
invoice.SubscriptionId,
|
||||
invoice.HostedInvoiceUrl,
|
||||
invoice.Total,
|
||||
invoice.Currency,
|
||||
ct)
|
||||
.ConfigureAwait(false);
|
||||
break;
|
||||
|
||||
case "customer.subscription.updated":
|
||||
Debug.Assert(stripeSubscription != null);
|
||||
await membershipNotifier
|
||||
.NotifySubscriptionUpdatedAsync(
|
||||
stripeSubscription.Id,
|
||||
stripeSubscription.CancelAt ?? stripeSubscription.CanceledAt,
|
||||
ct)
|
||||
.ConfigureAwait(false);
|
||||
break;
|
||||
case "customer.subscription.deleted":
|
||||
Debug.Assert(stripeSubscription != null);
|
||||
await membershipNotifier
|
||||
.NotifySubscriptionDeletedAsync(
|
||||
stripeSubscription.Id,
|
||||
ct)
|
||||
.ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
|
||||
await SendOkAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Contracts;
|
||||
using Hutopy.Modules.Memberships.Contracts;
|
||||
using Hutopy.Modules.Memberships.Data;
|
||||
|
||||
namespace Hutopy.Modules.Memberships.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public class SubscribeRequest
|
||||
{
|
||||
public Guid CreatorId { get; set; }
|
||||
public Guid MembershipTierId { get; set; }
|
||||
public required string CheckoutSuccessUrl { get; init; }
|
||||
public required string CheckoutCancelledUrl { get; init; }
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public record struct SubscriptionResponse(
|
||||
string StripeCheckoutUrl);
|
||||
|
||||
[PublicAPI]
|
||||
public class SubscribeValidator : Validator<SubscribeRequest>
|
||||
{
|
||||
public SubscribeValidator()
|
||||
{
|
||||
RuleFor(x => x.MembershipTierId).NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class SubscribeHandler(
|
||||
MembershipsDbContext dbContext,
|
||||
ICreatorLookup creatorLookup,
|
||||
IMembershipPaymentProcessor membershipPaymentProcessor)
|
||||
: Endpoint<SubscribeRequest, SubscriptionResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/memberships/subscribe");
|
||||
Options(o => o.WithTags("Memberships"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
SubscribeRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
MembershipTier? tier = await dbContext
|
||||
.MembershipTiers
|
||||
.Where(tier => tier.Id == req.MembershipTierId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (tier == null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
CreatorReference? creator = await creatorLookup.GetCreatorAsync(tier.CreatorId, ct);
|
||||
if (creator == null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!creator.AcceptCharges)
|
||||
{
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process Stripe subscription
|
||||
MembershipCheckoutSession checkoutSession = await membershipPaymentProcessor.CreateCheckoutSessionAsync(
|
||||
User.GetUserId(),
|
||||
creator,
|
||||
tier.Id,
|
||||
tier.StripePriceId,
|
||||
req.CheckoutSuccessUrl,
|
||||
req.CheckoutCancelledUrl);
|
||||
|
||||
await SendOkAsync(
|
||||
new SubscriptionResponse { StripeCheckoutUrl = checkoutSession.Url },
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Hutopy.Modules.Memberships.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Modules.Memberships.Migrations
|
||||
{
|
||||
[DbContext(typeof(MembershipsDbContext))]
|
||||
[Migration("20250609212641_Initial")]
|
||||
partial class Initial
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("Memberships")
|
||||
.HasAnnotation("ProductVersion", "9.0.3")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Membership", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("CreatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("EndDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("MembershipTierId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("StartDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("State")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("StripeSubscriptionId")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<Guid>("TierId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MembershipTierId");
|
||||
|
||||
b.ToTable("Memberships", "Memberships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.MembershipTier", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CreatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CurrencyCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<decimal>("Price")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<string>("StripePriceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("StripeProductId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("MembershipTiers", "Memberships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Payment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)");
|
||||
|
||||
b.Property<string>("InvoiceUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<Guid?>("MembershipId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MembershipId");
|
||||
|
||||
b.ToTable("Payments", "Memberships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Membership", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Modules.Memberships.Data.MembershipTier", "MembershipTier")
|
||||
.WithMany("Subscriptions")
|
||||
.HasForeignKey("MembershipTierId");
|
||||
|
||||
b.Navigation("MembershipTier");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Payment", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Modules.Memberships.Data.Membership", null)
|
||||
.WithMany("Payments")
|
||||
.HasForeignKey("MembershipId");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Membership", b =>
|
||||
{
|
||||
b.Navigation("Payments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.MembershipTier", b =>
|
||||
{
|
||||
b.Navigation("Subscriptions");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Modules.Memberships.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Initial : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "Memberships");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MembershipTiers",
|
||||
schema: "Memberships",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
|
||||
Price = table.Column<decimal>(type: "numeric", nullable: false),
|
||||
CurrencyCode = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
StripeProductId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
StripePriceId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
|
||||
DeletedBy = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MembershipTiers", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Memberships",
|
||||
schema: "Memberships",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
TierId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
MembershipTierId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
State = table.Column<int>(type: "integer", nullable: false),
|
||||
StartDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
EndDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
StripeSubscriptionId = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Memberships", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Memberships_MembershipTiers_MembershipTierId",
|
||||
column: x => x.MembershipTierId,
|
||||
principalSchema: "Memberships",
|
||||
principalTable: "MembershipTiers",
|
||||
principalColumn: "Id");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Payments",
|
||||
schema: "Memberships",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
|
||||
Amount = table.Column<decimal>(type: "numeric", nullable: false),
|
||||
Currency = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: false),
|
||||
InvoiceUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
|
||||
MembershipId = table.Column<Guid>(type: "uuid", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Payments", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Payments_Memberships_MembershipId",
|
||||
column: x => x.MembershipId,
|
||||
principalSchema: "Memberships",
|
||||
principalTable: "Memberships",
|
||||
principalColumn: "Id");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Memberships_MembershipTierId",
|
||||
schema: "Memberships",
|
||||
table: "Memberships",
|
||||
column: "MembershipTierId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Payments_MembershipId",
|
||||
schema: "Memberships",
|
||||
table: "Payments",
|
||||
column: "MembershipId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Payments",
|
||||
schema: "Memberships");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Memberships",
|
||||
schema: "Memberships");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MembershipTiers",
|
||||
schema: "Memberships");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Hutopy.Modules.Memberships.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hutopy.Modules.Memberships.Migrations
|
||||
{
|
||||
[DbContext(typeof(MembershipsDbContext))]
|
||||
partial class MembershipsDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("Memberships")
|
||||
.HasAnnotation("ProductVersion", "9.0.3")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Membership", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("CreatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("EndDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("MembershipTierId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("StartDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("State")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("StripeSubscriptionId")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<Guid>("TierId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MembershipTierId");
|
||||
|
||||
b.ToTable("Memberships", "Memberships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.MembershipTier", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CreatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CurrencyCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<decimal>("Price")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<string>("StripePriceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("StripeProductId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("MembershipTiers", "Memberships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Payment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)");
|
||||
|
||||
b.Property<string>("InvoiceUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<Guid?>("MembershipId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MembershipId");
|
||||
|
||||
b.ToTable("Payments", "Memberships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Membership", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Modules.Memberships.Data.MembershipTier", "MembershipTier")
|
||||
.WithMany("Subscriptions")
|
||||
.HasForeignKey("MembershipTierId");
|
||||
|
||||
b.Navigation("MembershipTier");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Payment", b =>
|
||||
{
|
||||
b.HasOne("Hutopy.Modules.Memberships.Data.Membership", null)
|
||||
.WithMany("Payments")
|
||||
.HasForeignKey("MembershipId");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Membership", b =>
|
||||
{
|
||||
b.Navigation("Payments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.MembershipTier", b =>
|
||||
{
|
||||
b.Navigation("Subscriptions");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
using Hutopy.Modules.Memberships.Contracts;
|
||||
using Hutopy.Modules.Memberships.Data;
|
||||
|
||||
namespace Hutopy.Modules.Memberships.Services;
|
||||
|
||||
public class MembershipNotifier(
|
||||
MembershipsDbContext dbContext)
|
||||
: IMembershipNotifier
|
||||
{
|
||||
public async Task NotifyCheckoutSessionCompleted(
|
||||
string stripeSessionId,
|
||||
string stripeSubscriptionId,
|
||||
string userId,
|
||||
string creatorId,
|
||||
string tierId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Membership membership = new()
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UserId = Guid.Parse(userId),
|
||||
CreatorId = Guid.Parse(creatorId),
|
||||
TierId = Guid.Parse(tierId),
|
||||
StripeSubscriptionId = stripeSubscriptionId,
|
||||
State = MembershipState.Pending,
|
||||
StartDate = null,
|
||||
EndDate = null
|
||||
};
|
||||
|
||||
dbContext.Memberships.Add(membership);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task NotifyPaymentSucceedAsync(
|
||||
string stripeSubscriptionId,
|
||||
string hostedInvoiceUrl,
|
||||
decimal amount,
|
||||
string currency,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Membership? membership = await dbContext
|
||||
.Memberships
|
||||
.SingleOrDefaultAsync(
|
||||
m => m.StripeSubscriptionId == stripeSubscriptionId,
|
||||
cancellationToken);
|
||||
if (membership is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Payment payment = new()
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Amount = amount,
|
||||
Currency = currency,
|
||||
InvoiceUrl = hostedInvoiceUrl
|
||||
};
|
||||
|
||||
membership.State = MembershipState.Active;
|
||||
membership.StartDate = DateTimeOffset.UtcNow;
|
||||
membership.Payments.Add(payment);
|
||||
|
||||
dbContext.Payments.Add(payment);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task NotifySubscriptionUpdatedAsync(
|
||||
string subscriptionId,
|
||||
DateTimeOffset? endDate,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Membership? membership = await dbContext
|
||||
.Memberships
|
||||
.SingleOrDefaultAsync(
|
||||
s => s.StripeSubscriptionId == subscriptionId,
|
||||
cancellationToken);
|
||||
if (membership == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
membership.EndDate = endDate;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task NotifySubscriptionDeletedAsync(
|
||||
string subscriptionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Membership? membership = await dbContext
|
||||
.Memberships
|
||||
.SingleOrDefaultAsync(
|
||||
s => s.StripeSubscriptionId == subscriptionId,
|
||||
cancellationToken);
|
||||
if (membership == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
membership.State = MembershipState.Inactive;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Hutopy.Common.Domain;
|
||||
|
||||
namespace Hutopy.Modules.Messaging.Data;
|
||||
|
||||
public class Message : Entity
|
||||
{
|
||||
public Guid SubjectId { get; set; }
|
||||
public Guid? ParentId { get; set; }
|
||||
[MaxLength(2048)] public required string Value { get; set; }
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
using Hutopy.Modules.Identity.Contracts;
|
||||
using Hutopy.Modules.Messaging.Models;
|
||||
|
||||
namespace Hutopy.Modules.Messaging.Data;
|
||||
|
||||
public class MessagingDbContext(
|
||||
IUserLookup userLookup,
|
||||
DbContextOptions<MessagingDbContext> options)
|
||||
: DbContext(options)
|
||||
{
|
||||
public const string SchemaName = "Messaging";
|
||||
|
||||
public DbSet<Message> Messages { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema(SchemaName);
|
||||
|
||||
modelBuilder
|
||||
.Entity<Message>()
|
||||
.Property(c => c.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<MessageDto>> GetMessagesAsync(
|
||||
Guid subjectId,
|
||||
Guid? parentId,
|
||||
Guid? lastId,
|
||||
int pageSize,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<Message> query = Messages
|
||||
.Where(c => c.SubjectId == subjectId)
|
||||
.Where(c => c.ParentId == parentId);
|
||||
|
||||
if (lastId.HasValue)
|
||||
{
|
||||
var lastMessage = await Messages
|
||||
.Where(c => c.Id == lastId.Value)
|
||||
.Select(c => new { c.CreatedAt, c.Id })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (lastMessage != null)
|
||||
{
|
||||
query = query
|
||||
.Where(c => c.CreatedAt < lastMessage.CreatedAt
|
||||
|| (c.CreatedAt == lastMessage.CreatedAt && c.Id < lastMessage.Id));
|
||||
}
|
||||
}
|
||||
|
||||
List<Message> messages = await query
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.ThenByDescending(c => c.Id)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(ct);
|
||||
|
||||
|
||||
MessageDto[] result = await Task.WhenAll(
|
||||
messages.Select(async message =>
|
||||
{
|
||||
UserReference? writer = await userLookup.GetUserAsync(message.CreatedBy, ct);
|
||||
return new MessageDto(
|
||||
message.Id,
|
||||
message.SubjectId,
|
||||
message.CreatedBy,
|
||||
writer?.Fullname ?? "Unknown User",
|
||||
writer?.PortraitUrl,
|
||||
message.CreatedAt,
|
||||
message.ParentId,
|
||||
message.Value);
|
||||
}));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<int> GetMessageCountAsync(
|
||||
Guid subjectId,
|
||||
Guid? parentId,
|
||||
int pageSize,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<Message> query = Messages
|
||||
.Where(c => c.SubjectId == subjectId)
|
||||
.Where(c => c.ParentId == parentId);
|
||||
|
||||
int messageCount = await query
|
||||
.Take(pageSize)
|
||||
.CountAsync(ct);
|
||||
|
||||
return messageCount;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using Hutopy.Modules.Messaging.Data;
|
||||
|
||||
namespace Hutopy.Modules.Messaging;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddMessagingModule(
|
||||
this WebApplicationBuilder builder,
|
||||
Action<DbContextOptionsBuilder>? configureAction = null)
|
||||
{
|
||||
builder.Services.AddDbContext<MessagingDbContext>(configureAction);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static async Task<IApplicationBuilder> UseMessagingModuleAsync(
|
||||
this IApplicationBuilder app,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IServiceScopeFactory scopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>();
|
||||
using IServiceScope scope = scopeFactory.CreateScope();
|
||||
await using MessagingDbContext context = scope.ServiceProvider.GetRequiredService<MessagingDbContext>();
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user