feat: pivot to social media workflow app
This commit is contained in:
4
.github/workflows/backend-ci.yml
vendored
4
.github/workflows/backend-ci.yml
vendored
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
env:
|
||||
AZURE_WEBAPP_NAME: hutopy-backend-api
|
||||
DOTNET_VERSION: '9.0.x'
|
||||
DOTNET_VERSION: '10.0.x'
|
||||
|
||||
jobs:
|
||||
build_and_deploy:
|
||||
@@ -36,4 +36,4 @@ jobs:
|
||||
with:
|
||||
app-name: ${{ env.AZURE_WEBAPP_NAME }}
|
||||
publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
|
||||
package: './backend/publish/publish/Hutopy/release/'
|
||||
package: './backend/publish/publish/Socialize.Api/release/'
|
||||
|
||||
135
AGENTS.md
135
AGENTS.md
@@ -3,9 +3,35 @@
|
||||
## 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.
|
||||
|
||||
## Documentation-First Workflow
|
||||
Agents must treat repository documentation as the source of truth. Conversation history is secondary and may be incomplete, stale, or contradictory.
|
||||
|
||||
Before making any substantial code change, agents must read the relevant docs first. At minimum, inspect:
|
||||
- `AGENTS.md`
|
||||
- `docs/LLM_DEVELOPMENT_WORKFLOW.md`
|
||||
- `docs/PRODUCT.md` when product behavior, UX, or user workflow may change
|
||||
- `docs/ARCHITECTURE.md` when structure, module boundaries, routing, data flow, or integration points may change
|
||||
- `docs/CONVENTIONS.md` when adding or modifying code patterns
|
||||
- `docs/DECISIONS.md` before revisiting architecture or product decisions
|
||||
- the active `docs/tasks/TASK-*.md` file when one exists
|
||||
|
||||
If one of these files does not exist yet, do not invent broad behavior from chat history. State what is missing and proceed only with the narrowest safe interpretation of the current task.
|
||||
|
||||
For non-trivial work, follow this sequence:
|
||||
1. Read the relevant docs and existing code.
|
||||
2. Restate the task in a short summary.
|
||||
3. Identify backend impact, frontend impact, data impact, and documentation impact.
|
||||
4. List files likely to change.
|
||||
5. Surface ambiguities or risky assumptions.
|
||||
6. Propose a minimal implementation plan.
|
||||
7. Implement only the approved or clearly requested scope.
|
||||
8. Validate with the relevant commands before finishing when possible.
|
||||
|
||||
Do not use a long chat thread as the durable memory for the project. Durable decisions, conventions, and task requirements belong in repository docs.
|
||||
|
||||
## Pair Working Mode
|
||||
- Work as a pair with the repository owner, not as an isolated implementer.
|
||||
- Before substantial changes, restate the task briefly and inspect the existing code or docs first.
|
||||
- Before substantial changes, read the relevant docs first, then restate the task briefly and inspect the existing code.
|
||||
- Surface assumptions, tradeoffs, and blockers early instead of silently picking risky directions.
|
||||
- Prefer small, reviewable increments when the product direction is still being shaped.
|
||||
- When requirements are exploratory, help turn them into concrete workflows, domain language, and next implementation steps.
|
||||
@@ -14,29 +40,34 @@ This document is a working guide for coding agents in this repository. It captur
|
||||
- When creating commits, use the Conventional Commits format, for example `docs: update product planning`.
|
||||
|
||||
## Repository Layout
|
||||
- `backend/`: ASP.NET Core (`net9.0`) API using FastEndpoints, EF Core (PostgreSQL), Stripe, Azure Blob Storage, and ASP.NET Identity.
|
||||
- `backend/`: ASP.NET Core (`net10.0`) API using FastEndpoints, EF Core (PostgreSQL), ASP.NET Identity, and modular bounded contexts for workflow data.
|
||||
- `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).
|
||||
- `.github/workflows/`: build/deploy pipelines for backend (Azure Web App) and frontend (Azure Static Web Apps).
|
||||
|
||||
## Local Runbook
|
||||
### Backend
|
||||
- Prereqs: .NET 9 SDK, Docker, PostgreSQL container.
|
||||
- Prereqs: .NET 10 SDK, Docker, PostgreSQL container.
|
||||
- Start database:
|
||||
- `cd backend`
|
||||
- `./scripts/start-infrastructure.sh`
|
||||
- Run API:
|
||||
- `dotnet run` (from `backend/`)
|
||||
- `dotnet run --project Socialize.Api.csproj` (from `backend/`)
|
||||
- Local API URL:
|
||||
- `http://localhost:5000`
|
||||
- Swagger/OpenAPI UI in dev:
|
||||
- `/api`
|
||||
|
||||
### Frontend
|
||||
- Prereqs: Node/npm, local HTTPS cert files expected by Vite:
|
||||
- `frontend/localhost-key.pem`
|
||||
- `frontend/localhost.pem`
|
||||
- Prereqs: Node/npm.
|
||||
- Runtime configuration:
|
||||
- frontend app config is loaded from `.env.development` and `.env.production`
|
||||
- `frontend/src/config.js` is the single frontend source of truth for runtime env access
|
||||
- Commands:
|
||||
- `cd frontend && npm install`
|
||||
- `npm run dev`
|
||||
- `npm run build`
|
||||
- Local dev server:
|
||||
- `http://localhost:5173`
|
||||
|
||||
## Backend Architecture
|
||||
### Composition Root
|
||||
@@ -44,7 +75,7 @@ This document is a working guide for coding agents in this repository. It captur
|
||||
- Registers:
|
||||
- Web services/auth (`backend/DependencyInjection.cs`)
|
||||
- Infrastructure services (`backend/Infrastructure/DependencyInjection.cs`)
|
||||
- Modules: Identity, Creators, Contents, Memberships, Tipping, Messaging.
|
||||
- Modules: Identity, Workspaces, Clients, Projects, ContentItems, Assets, Comments, Approvals, Notifications.
|
||||
- Each module has:
|
||||
- `Add{Module}Module(...)` to register DbContext/services.
|
||||
- `Use{Module}ModuleAsync()` to auto-run migrations at startup.
|
||||
@@ -57,78 +88,102 @@ This document is a working guide for coding agents in this repository. It captur
|
||||
|
||||
### Data Boundaries
|
||||
- Separate DbContext per module:
|
||||
- Identity, Creators, Contents, Memberships, Tipping, Messaging.
|
||||
- Identity, Workspaces, Clients, Projects, ContentItems, Assets, Comments, Approvals, Notifications.
|
||||
- Migrations are module-scoped under each `Modules/*/Migrations` folder.
|
||||
|
||||
### 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`.
|
||||
- Role-gated frontend routes currently use `Administrator` and `Manager` checks for settings access.
|
||||
|
||||
### 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`
|
||||
|
||||
### Blob Storage
|
||||
- `IBlobStorage` implemented by `AzureBlobStorage`.
|
||||
- Upload size/type checks are enforced there (10 MB max + content-type validation).
|
||||
### Current Domain Modules
|
||||
- `Identity`: authentication, refresh tokens, email verification, password reset, social login.
|
||||
- `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.
|
||||
|
||||
## Frontend Architecture
|
||||
### Bootstrap
|
||||
- `frontend/src/main.js` wires Vue app + Pinia + Vuetify + Router + i18n + Google OAuth + Toasts.
|
||||
- `frontend/src/config.js` is the app-facing runtime configuration module. Do not scatter `import.meta.env` reads across the app.
|
||||
|
||||
### Routing
|
||||
- Defined in `frontend/src/router/router.js`.
|
||||
- Route guards enforce:
|
||||
- `meta.requiresAuth`
|
||||
- `meta.notAuthenticated`
|
||||
- Creator public route convention: `/@:creator`.
|
||||
- optional `meta.roles`
|
||||
- Primary authenticated app routes live under `/app/*`.
|
||||
|
||||
### State Management
|
||||
- Pinia stores:
|
||||
- `authStore`: token lifecycle + refresh concurrency guard.
|
||||
- `workspaceStore`: active workspace context.
|
||||
- `clientsStore`: client list and creation flows.
|
||||
- `projectsStore`: project list and creation flows.
|
||||
- `contentItemsStore` and `contentItemDetailStore`: content item listing/detail flows.
|
||||
- `reviewQueueStore`: pending review work.
|
||||
- `notificationsStore`: workflow notifications.
|
||||
- `userProfileStore`: current user profile and account edits.
|
||||
- `creatorProfileStore`: creator-owned profile actions.
|
||||
- `brandingStore`: creator page branding fetched from slug route param.
|
||||
|
||||
### API Client
|
||||
- Axios client in `frontend/src/plugins/api.js`.
|
||||
- Injects bearer token, proactively refreshes near expiry, retries once on 401.
|
||||
|
||||
## 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/*`).
|
||||
- Identity and social login (`backend/Modules/Identity/*`, `frontend/src/views/auth/*`).
|
||||
- Workspace-scoped operations and role checks (`backend/Modules/Workspaces/*`, `frontend/src/stores/workspaceStore.js`, `frontend/src/router/router.js`).
|
||||
- Client and project workflow (`backend/Modules/Clients/*`, `backend/Modules/Projects/*`, `frontend/src/views/app/ClientsView.vue`, `frontend/src/views/app/ProjectsView.vue`).
|
||||
- Content review lifecycle (`backend/Modules/ContentItems/*`, `backend/Modules/Assets/*`, `backend/Modules/Comments/*`, `backend/Modules/Approvals/*`, `frontend/src/views/app/ContentItemsView.vue`, `frontend/src/views/app/ContentItemDetailView.vue`, `frontend/src/views/app/ReviewQueueView.vue`).
|
||||
- Notifications and workflow awareness (`backend/Modules/Notifications/*`, `frontend/src/stores/notificationsStore.js`).
|
||||
|
||||
## Task-Driven Development With Agents
|
||||
Use `docs/tasks/TASK-*.md` files as LLM-friendly implementation tickets. A task file should be self-contained enough for a fresh agent to understand the desired change without relying on a long conversation.
|
||||
|
||||
A good task file defines:
|
||||
- objective and product context
|
||||
- scope and out of scope
|
||||
- backend requirements, API contract, validation, data, authorization
|
||||
- frontend requirements, route/screen, components, state, API integration, UX states
|
||||
- files likely involved
|
||||
- acceptance criteria
|
||||
- validation plan
|
||||
- risks and open questions
|
||||
|
||||
Features are fullstack by default unless the task explicitly says otherwise. Do not assume a feature is backend-only. For user-facing work, define both backend and frontend behavior before implementation.
|
||||
|
||||
When an adjacent issue is discovered outside the task scope, do not fix it opportunistically. Report it as a suggested backlog item or add it to `docs/BACKLOG.md` if explicitly asked.
|
||||
|
||||
## 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.
|
||||
5. Keep frontend runtime configuration centralized in `frontend/src/config.js` and `.env.*`; do not introduce ad hoc env fallbacks.
|
||||
6. Preserve workspace scoping and route-role checks when editing app flows.
|
||||
7. Do not commit secrets. Existing appsettings and env files include sensitive-looking values; treat them as legacy and avoid propagating.
|
||||
8. For non-trivial features, prefer a `docs/tasks/TASK-*.md` file before implementation.
|
||||
9. Treat frontend behavior as part of the feature definition: route, components, Pinia store usage, API integration, loading/error/success states, and navigation must be explicit or derived from existing patterns.
|
||||
10. If requirements conflict with repository docs, stop and surface the conflict instead of silently choosing one.
|
||||
|
||||
## Validation Checklist Before Finishing
|
||||
- Backend:
|
||||
- `cd backend && dotnet build`
|
||||
- run affected endpoint flows if change touches handlers/auth/payments/storage
|
||||
- `cd backend && dotnet build Socialize.Api.csproj`
|
||||
- run affected endpoint flows if change touches handlers/auth/workspace scoping/data writes
|
||||
- Frontend:
|
||||
- `cd frontend && npm run build`
|
||||
- validate affected route/store interactions in browser
|
||||
- validate affected route/store interactions in browser when UI behavior changed
|
||||
- If migrations were changed:
|
||||
- ensure module context name/output directory remain consistent with `backend/scripts/add-migration.sh`.
|
||||
|
||||
## 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.
|
||||
- Frontend config should come through `.env.development` / `.env.production` and `frontend/src/config.js`; avoid direct `import.meta.env` reads in feature code.
|
||||
- Backend development now runs on HTTP locally (`http://localhost:5000`), while HTTPS redirection stays enabled outside development.
|
||||
- `frontend/.env.development` is currently checked in and points `VITE_API_URL` to `http://192.168.1.2:5000`; verify whether changes should target `localhost` or the LAN host before editing.
|
||||
- Some style/formatting is inconsistent across JS/Vue/C# files; minimize churn to touched lines.
|
||||
|
||||
152
README.md
152
README.md
@@ -1,110 +1,88 @@
|
||||
# Hutopy
|
||||
# Socialize
|
||||
|
||||
## Patterns / strategy used
|
||||
- Clean Architecture ( with Infrastructure, Domain, Application and Web layers )
|
||||
- Minimal API endpoints.
|
||||
Socialize is a 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 current product direction is a workspace-based review tool for internal teams, providers, and client approvers.
|
||||
|
||||
## 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
|
||||
```
|
||||
## Repository Structure
|
||||
|
||||
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/`: ASP.NET Core `net10.0` API with FastEndpoints, EF Core, PostgreSQL, and modular bounded contexts.
|
||||
- `frontend/`: Vue 3 + Vite + Vuetify + Pinia SPA.
|
||||
- `docs/`: product, planning, and archived project documentation.
|
||||
|
||||
## 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
|
||||
## Current Backend Modules
|
||||
|
||||
- `Identity`
|
||||
- `Workspaces`
|
||||
- `Clients`
|
||||
- `Projects`
|
||||
- `ContentItems`
|
||||
- `Assets`
|
||||
- `Comments`
|
||||
- `Approvals`
|
||||
- `Notifications`
|
||||
|
||||
```
|
||||
## Local Development
|
||||
|
||||
## Entity Framework
|
||||
### Backend
|
||||
|
||||
Create a new migration :
|
||||
```
|
||||
./Ef.ps1 migrations add NomDeLaMigration
|
||||
```
|
||||
Prerequisites:
|
||||
|
||||
Update database :
|
||||
```
|
||||
./Ef.ps1 database update
|
||||
```
|
||||
- .NET 10 SDK
|
||||
- Docker
|
||||
|
||||
## 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:
|
||||
Start infrastructure:
|
||||
|
||||
```bash
|
||||
cd .\src\Web\
|
||||
dotnet watch run
|
||||
cd backend
|
||||
./scripts/start-infrastructure.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:
|
||||
Run the API:
|
||||
|
||||
```bash
|
||||
dotnet new install Clean.Architecture.Solution.Template::8.0.4
|
||||
cd backend
|
||||
dotnet run --project Socialize.Api.csproj
|
||||
```
|
||||
|
||||
## Test
|
||||
Local backend URL:
|
||||
|
||||
The solution contains unit, integration, and functional tests.
|
||||
- `http://localhost:5000`
|
||||
- Swagger UI: `http://localhost:5000/api`
|
||||
|
||||
- Using Moq, Nunit, Respawn, FluentAssertions
|
||||
### Frontend
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Node.js / npm
|
||||
|
||||
The frontend reads runtime values from:
|
||||
|
||||
- `frontend/.env.development`
|
||||
- `frontend/.env.production`
|
||||
- `frontend/src/config.js`
|
||||
|
||||
Run the frontend:
|
||||
|
||||
To run the tests:
|
||||
```bash
|
||||
dotnet test
|
||||
```
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Local frontend URL:
|
||||
|
||||
- `http://localhost:5173`
|
||||
|
||||
## Validation
|
||||
|
||||
- Backend: `cd backend && dotnet build Socialize.Api.csproj`
|
||||
- Frontend: `cd frontend && npm run build`
|
||||
|
||||
## Docs
|
||||
|
||||
- [docs/README.md](/home/jbourdon/repos/social-media/docs/README.md)
|
||||
- [docs/product/vision.md](/home/jbourdon/repos/social-media/docs/product/vision.md)
|
||||
- [docs/product/glossary.md](/home/jbourdon/repos/social-media/docs/product/glossary.md)
|
||||
- [docs/constraints.md](/home/jbourdon/repos/social-media/docs/constraints.md)
|
||||
- [AGENTS.md](/home/jbourdon/repos/social-media/AGENTS.md)
|
||||
|
||||
328
SOCIALIZE.fr.md
328
SOCIALIZE.fr.md
@@ -1,328 +0,0 @@
|
||||
# Flux d'approbation pour les contenus de medias sociaux
|
||||
|
||||
Nom temporaire du produit : `Socialize`
|
||||
|
||||
## Intention du projet
|
||||
|
||||
Construire `Socialize`, une application qui remplace le processus actuel d'approbation base sur Google Drive, les appels telephoniques, les courriels et les feuilles de calcul.
|
||||
|
||||
Le produit n'est pas un reseau social public. C'est un outil de flux de travail interne/externe pour la revision de contenu, la collecte de commentaires, l'approbation et la preparation a la publication.
|
||||
|
||||
## Vocabulaire partage
|
||||
|
||||
- Flux d'approbation : le processus complet entre la creation d'un brouillon et l'approbation finale.
|
||||
- Element de contenu : l'unite a reviser qui regroupe les fichiers, le message de publication ou le texte, les dates et les canaux cibles.
|
||||
- Ressource : un fichier rattache a un element de contenu, par exemple une video, une image ou un document.
|
||||
- Revision : une nouvelle version d'une ressource ou d'un texte apres commentaires.
|
||||
- Reviseur externe : un client ou un partenaire qui revise du contenu sans faire partie de l'equipe interne.
|
||||
- Fournisseur : un partenaire de production externe, par exemple une equipe video, un photographe, un monteur ou un designer, qui peut livrer des brouillons et recevoir des demandes de modifications.
|
||||
- Software as a Service (SaaS) / logiciel en tant que service : un produit en ligne utilise via le web, comme Canva, MailChimp, HootSuite ou Metricool.
|
||||
- Minimum Viable Product (MVP) / produit minimum viable : la plus petite version du produit qui regle suffisamment bien le probleme principal pour valider le marche.
|
||||
- Service Level Agreement (SLA) / accord de niveau de service : une cible de service convenue, par exemple une date limite de revision ou un seuil d'escalade.
|
||||
|
||||
## Enonce du probleme
|
||||
|
||||
Les gestionnaires de medias sociaux et les equipes de production gerent actuellement les approbations de contenu de facon manuelle :
|
||||
|
||||
- Les ressources sont stockees dans Google Drive.
|
||||
- Le gestionnaire de medias sociaux fait souvent des allers-retours autant avec les fournisseurs qu'avec les clients.
|
||||
- Les commentaires circulent par telephone, courriel, message et feuille de calcul.
|
||||
- L'historique des versions est flou.
|
||||
- Il est difficile de savoir quel fichier est le plus recent.
|
||||
- Les commentaires sont disperses sur plusieurs canaux.
|
||||
- Les approbations internes et celles des clients suivent des logiques semblables mais ne sont pas centralisees.
|
||||
- Les suivis sont manuels, ce qui retarde les approbations.
|
||||
|
||||
Resultat : trop d'allers-retours, peu de tracabilite, des delais evitables et un risque de publier le mauvais fichier ou une version de texte perimee.
|
||||
|
||||
## Outils observes actuellement
|
||||
|
||||
- Google Drive pour les videos, images, calendriers et documents
|
||||
- Google Sheets ou equivalent pour suivre les commentaires et les statuts
|
||||
- Telephone et courriel pour les conversations de revision et d'approbation
|
||||
- HootSuite
|
||||
- Metricool
|
||||
- Canva
|
||||
- MailChimp
|
||||
|
||||
## Utilisateurs principaux
|
||||
|
||||
- Gestionnaire de medias sociaux
|
||||
- Gestionnaire de compte / service client
|
||||
- Approbateur cote client
|
||||
- Fournisseur externe / partenaire de production
|
||||
- Producteur interne
|
||||
- Employe interne / contributeur au contenu
|
||||
- Administrateur
|
||||
|
||||
## Cas d'utilisation principaux
|
||||
|
||||
### 1. Flux d'approbation client
|
||||
|
||||
Un gestionnaire de medias sociaux prepare du contenu pour un client et le soumet pour approbation.
|
||||
|
||||
Le client doit pouvoir :
|
||||
|
||||
- consulter le lot de contenu
|
||||
- previsualiser les fichiers
|
||||
- lire les legendes, descriptions et notes de projet
|
||||
- laisser des commentaires
|
||||
- demander des modifications
|
||||
- approuver ou rejeter
|
||||
|
||||
L'equipe doit pouvoir :
|
||||
|
||||
- voir le statut d'approbation en temps reel
|
||||
- repondre aux commentaires dans leur contexte
|
||||
- televerser des versions revisees
|
||||
- conserver une piste d'audit claire indiquant qui a dit quoi et quand
|
||||
- savoir exactement quelle version est approuvee
|
||||
|
||||
### 2. Flux de production interne
|
||||
|
||||
Le meme flux doit fonctionner a l'interne pour les producteurs, employes et partenaires de production externes avant que le contenu soit montre au client ou planifie pour publication.
|
||||
|
||||
Exemple :
|
||||
|
||||
- un contributeur televerse un brouillon
|
||||
- un fournisseur externe peut televerser un brouillon ou une version revisee
|
||||
- un producteur revise et demande des modifications
|
||||
- un gestionnaire approuve pour la revision client
|
||||
- le client approuve
|
||||
- le contenu est marque pret pour la publication
|
||||
|
||||
### 3. Revision d'un lot de contenu
|
||||
|
||||
L'approbation ne doit pas se limiter a un seul fichier. Un element a reviser peut inclure :
|
||||
|
||||
- video
|
||||
- image
|
||||
- document
|
||||
- message de publication / legende / texte
|
||||
- mots-clics
|
||||
- liens
|
||||
- dates de publication
|
||||
- canaux cibles ou reseaux sociaux
|
||||
|
||||
## Resume du flux actuel
|
||||
|
||||
Flux actuel typique :
|
||||
|
||||
1. L'equipe cree les ressources media.
|
||||
2. Les fichiers sont places dans Google Drive par l'equipe ou par des fournisseurs externes.
|
||||
3. Un gestionnaire envoie les liens par courriel ou message aux fournisseurs, aux intervenants internes ou aux clients.
|
||||
4. Les commentaires reviennent par telephone, courriel, feuille de calcul ou clavardage.
|
||||
5. L'equipe consolide manuellement les commentaires provenant des fournisseurs et des clients.
|
||||
6. Une version revisee est televersee.
|
||||
7. Le cycle se repete jusqu'a ce que quelqu'un dise que c'est approuve.
|
||||
8. Le statut d'approbation est suivi manuellement ailleurs.
|
||||
|
||||
Principaux points d'echec :
|
||||
|
||||
- aucune source de verite unique
|
||||
- aucun etat d'approbation structure
|
||||
- aucun fil de commentaires centralise
|
||||
- aucun rappel d'echeance
|
||||
- aucune piste d'audit fiable
|
||||
- aucune barriere d'approbation avant publication
|
||||
|
||||
## Flux cible
|
||||
|
||||
1. Creer un projet et l'associer a un client.
|
||||
2. Creer un element de revision ou une demande d'approbation.
|
||||
3. Joindre des ressources ou les importer depuis Google Drive.
|
||||
4. Ajouter des metadonnees :
|
||||
- titre
|
||||
- message de publication / legende / texte
|
||||
- plateforme cible ou reseau social
|
||||
- dates de publication par reseau lorsque pertinent
|
||||
- date d'echeance
|
||||
- reviseur(s)
|
||||
5. Envoyer la demande de revision.
|
||||
6. Les reviseurs commentent directement sur l'element.
|
||||
7. L'equipe ou le fournisseur televerse une revision ou repond aux commentaires.
|
||||
8. Le systeme suit les versions, les changements de statut et les evenements du flux.
|
||||
9. Le reviseur approuve, rejette ou demande des modifications.
|
||||
10. Une fois toutes les approbations requises obtenues, l'element devient pret pour la planification ou la publication.
|
||||
|
||||
## Objets de domaine principaux
|
||||
|
||||
- Espace de travail : la frontiere principale du compte pour une agence ou une equipe operationnelle.
|
||||
- Client : l'entreprise, le createur ou la marque qui recoit le service et approuve le contenu.
|
||||
- Membre d'equipe : un utilisateur interne qui travaille sur le contenu, les revisions ou la coordination.
|
||||
- Reviseur : toute personne a qui l'on demande de reviser et d'approuver, qu'elle soit interne ou externe.
|
||||
- Fournisseur : un contributeur de production externe, comme un photographe, videaste, monteur ou designer.
|
||||
- Projet : le principal conteneur de travail pour un client, qui regroupe des elements de contenu, des notes, des participants et des echeances.
|
||||
- Element de contenu : l'unite a reviser qui contient les ressources, le message de publication, les canaux cibles, les dates d'echeance et l'etat d'approbation.
|
||||
- Ressource : un fichier joint, comme une video, une image ou un document, reference depuis Google Drive ou stocke directement.
|
||||
- Version de ressource : une revision precise d'une ressource, avec tracabilite de la personne qui l'a televersee et du moment.
|
||||
- Fil de commentaires : une discussion contextuelle rattachee a un element de contenu, une ressource ou une revision.
|
||||
- Demande d'approbation : l'action de demander a un ou plusieurs reviseurs de reviser une version precise.
|
||||
- Decision d'approbation : le resultat d'une demande de revision, par exemple approuve, rejete ou modifications demandees.
|
||||
- Historique des statuts : la piste d'audit des etats et transitions du flux dans le temps.
|
||||
- Cible de publication : la destination prevue pour la publication, par exemple Instagram, Facebook, LinkedIn ou une infolettre.
|
||||
- Evenement de notification : un evenement du flux qui informe les utilisateurs qu'un commentaire, une revision, une demande ou une approbation vient d'avoir lieu.
|
||||
|
||||
## Modele de statuts suggere
|
||||
|
||||
- Brouillon
|
||||
- En revision interne
|
||||
- Modifications demandees a l'interne
|
||||
- Modifications internes en cours
|
||||
- Pret pour revision client
|
||||
- En revision client
|
||||
- Modifications demandees par le client
|
||||
- Modifications client en cours
|
||||
- Approuve
|
||||
- Rejete
|
||||
- Pret a publier
|
||||
- Publie
|
||||
- Archive
|
||||
|
||||
## Portee du Minimum Viable Product (MVP) / produit minimum viable
|
||||
|
||||
La premiere version doit se concentrer sur le flux d'approbation, et non sur la publication directe.
|
||||
|
||||
### Fonctionnalites MVP
|
||||
|
||||
- authentification et roles utilisateurs
|
||||
- structure espace de travail / client / projet
|
||||
- creation d'un element de contenu avec metadonnees
|
||||
- televersement de ressources ou ajout de liens Google Drive tout en gardant Google Drive comme source de verite lorsque le client l'exige
|
||||
- suivi des versions pour les fichiers et les textes
|
||||
- commentaires centralises
|
||||
- decisions d'approbation : approuver, rejeter, demander des modifications
|
||||
- chronologie d'activite / piste d'audit
|
||||
- tableau de bord par client, projet et date d'echeance
|
||||
- notifications et rappels lorsque des actions sont completees ou que des evenements du flux surviennent
|
||||
- portail simple d'approbation pour les clients externes
|
||||
|
||||
### Fonctionnalites candidates fortes pour le MVP
|
||||
|
||||
- approbateurs obligatoires
|
||||
- date limite d'approbation
|
||||
- dates d'echeance par cible de publication ou reseau social
|
||||
- comparaison entre version courante et version precedente
|
||||
- indicateur de la "derniere version approuvee"
|
||||
- resolution des commentaires
|
||||
- filtres par statut, client, responsable et date d'echeance
|
||||
|
||||
## Possibilites pour la phase 2
|
||||
|
||||
- integration Google Drive avec synchronisation ou import de fichiers
|
||||
- export ou transfert vers HootSuite / Metricool
|
||||
- liaison avec les ressources Canva
|
||||
- flux d'approbation MailChimp pour les infolettres
|
||||
- integration calendrier pour la visibilite sur la planification des publications
|
||||
- commentaires annotes sur images ou sur horodatages video
|
||||
- modeles de flux d'approbation reutilisables par type de contenu
|
||||
- rappels et escalades bases sur les Service Level Agreements (SLA) / accords de niveau de service
|
||||
- analyses sur les temps de traitement et les goulots d'etranglement
|
||||
- approbation par lien recu par courriel
|
||||
- regles d'approbation a plusieurs etapes selon le client
|
||||
|
||||
## Possibilites d'automatisation importantes
|
||||
|
||||
- demander automatiquement une approbation lorsqu'un element atteint une etape definie
|
||||
- envoyer automatiquement des notifications lorsqu'une action est completee ou qu'un evenement du flux survient
|
||||
- envoyer automatiquement des rappels avant les echeances
|
||||
- escalader automatiquement lorsqu'une approbation est en retard
|
||||
- etiqueter automatiquement les versions
|
||||
- passer automatiquement a l'etat "pret a publier" lorsque toutes les approbations sont completees
|
||||
- conserver automatiquement une piste d'audit de chaque televersement, commentaire et decision
|
||||
- generer automatiquement un lien de revision cote client
|
||||
- notifier automatiquement lorsqu'une nouvelle revision repond aux modifications demandees
|
||||
|
||||
## Decisions produit importantes
|
||||
|
||||
### 1. Systeme de reference pour les ressources
|
||||
|
||||
Options :
|
||||
|
||||
- garder Google Drive comme stockage de fichiers et construire le flux autour
|
||||
- televerser les fichiers directement dans la nouvelle application
|
||||
- supporter les deux
|
||||
|
||||
Premiere hypothese recommandee :
|
||||
|
||||
Garder Google Drive comme source de verite lorsque le client exige d'en conserver la propriete, et supporter plus tard les televersements directs comme option. La premiere version doit fonctionner proprement avec les liens Drive et les metadonnees importees avant d'envisager une synchronisation plus poussee.
|
||||
|
||||
### 2. Experience du reviseur externe
|
||||
|
||||
Options :
|
||||
|
||||
- compte reviseur obligatoire
|
||||
- acces par lien magique sans compte complet
|
||||
- les deux
|
||||
|
||||
Premiere hypothese recommandee :
|
||||
|
||||
Utiliser l'acces par lien magique pour les clients afin de reduire la friction.
|
||||
|
||||
### 3. Granularite de l'approbation
|
||||
|
||||
Unites d'approbation possibles :
|
||||
|
||||
- element de contenu complet
|
||||
- par ressource
|
||||
- par legende / texte
|
||||
- par variation de canal
|
||||
|
||||
Premiere hypothese recommandee :
|
||||
|
||||
Approuver au niveau de l'element de contenu dans le Minimum Viable Product (MVP), avec des commentaires rattaches aux ressources et au texte.
|
||||
|
||||
## Regles d'affaires a confirmer
|
||||
|
||||
Ces points ne bloquent pas le cadrage initial, mais il faut les documenter tot pour que le comportement du produit corresponde bien au vrai processus d'approbation.
|
||||
|
||||
- Un client peut-il approuver s'il reste des commentaires non resolus ?
|
||||
- L'approbation exige-t-elle un seul reviseur ou plusieurs reviseurs ?
|
||||
- L'approbation interne et l'approbation client peuvent-elles se faire en parallele ?
|
||||
- L'approbation est-elle valide seulement pour la version la plus recente ?
|
||||
- Un element approuve peut-il etre modifie sans rouvrir la revision ?
|
||||
- Des clients differents ont-ils besoin de flux differents ?
|
||||
- Les videos, images et documents sont-ils tous aussi importants des le jour 1 ?
|
||||
- La planification ou publication fait-elle partie de la portee, ou seulement le passage a l'etat "pret a publier" ?
|
||||
|
||||
## Questions ouvertes pour la prochaine entrevue
|
||||
|
||||
- Qui est l'acheteur : agence, travailleur autonome ou equipe marketing interne ?
|
||||
- Le premier marche cible est-il l'approbation agence-client, l'approbation interne ou les deux ?
|
||||
- Quels types de contenu sont prioritaires : video, image, documents, legende, infolettres ?
|
||||
- A quelle frequence les clients demandent-ils des modifications apres une approbation verbale ?
|
||||
- Quelle est aujourd'hui l'etape la plus douloureuse ?
|
||||
- Quels outils doivent absolument rester en place au lancement ?
|
||||
- Quelles approbations exigent une tracabilite legale ou de conformite ?
|
||||
- Combien de reviseurs participent habituellement a chaque element ?
|
||||
- Le bilinguisme est-il requis ?
|
||||
- La revision mobile est-elle importante au jour 1 ?
|
||||
|
||||
## Criteres de succes du Minimum Viable Product (MVP) / produit minimum viable
|
||||
|
||||
- reduire le temps necessaire pour obtenir une approbation
|
||||
- reduire les allers-retours entre courriels, telephone et feuilles de calcul
|
||||
- fournir une source de verite claire pour la derniere version et le statut courant
|
||||
- permettre a un client d'approuver sans formation
|
||||
- permettre a l'equipe de voir instantanement les elements bloques
|
||||
|
||||
## Positionnement du produit
|
||||
|
||||
Ce produit devrait etre positionne comme suit :
|
||||
|
||||
"Un flux de revision et d'approbation pour le contenu de medias sociaux, et non un autre outil de creation de contenu."
|
||||
|
||||
La valeur se trouve dans la coordination, la tracabilite et l'acceleration des cycles d'approbation.
|
||||
|
||||
## Recommandation pour la premiere version
|
||||
|
||||
Construire la premiere version autour de ce flux etroit :
|
||||
|
||||
1. l'equipe cree un element de contenu
|
||||
2. l'equipe televerse les fichiers et le texte
|
||||
3. un reviseur interne commente et demande des modifications
|
||||
4. l'equipe soumet l'element au client
|
||||
5. le client commente et approuve via un lien simple
|
||||
6. l'element devient pret pour le transfert vers la publication
|
||||
|
||||
Si ce flux fonctionne proprement, les integrations et la planification pourront etre ajoutees ensuite.
|
||||
327
TEMPLATE_PROMPT.md
Normal file
327
TEMPLATE_PROMPT.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# PROMPT TEMPLATES
|
||||
|
||||
## 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.
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Hutopy.Common.Domain;
|
||||
namespace Socialize.Common.Domain;
|
||||
|
||||
public abstract class Entity
|
||||
{
|
||||
|
||||
226
backend/Data/AppDbContext.cs
Normal file
226
backend/Data/AppDbContext.cs
Normal file
@@ -0,0 +1,226 @@
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Socialize.Modules.Approvals.Data;
|
||||
using Socialize.Modules.Assets.Data;
|
||||
using Socialize.Modules.Clients.Data;
|
||||
using Socialize.Modules.Comments.Data;
|
||||
using Socialize.Modules.ContentItems.Data;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Socialize.Modules.Notifications.Data;
|
||||
using Socialize.Modules.Projects.Data;
|
||||
using Socialize.Modules.Workspaces.Data;
|
||||
|
||||
namespace Socialize.Data;
|
||||
|
||||
public class AppDbContext(
|
||||
DbContextOptions<AppDbContext> options)
|
||||
: IdentityDbContext<User, Role, Guid>(options)
|
||||
{
|
||||
public DbSet<Workspace> Workspaces => Set<Workspace>();
|
||||
public DbSet<WorkspaceInvite> WorkspaceInvites => Set<WorkspaceInvite>();
|
||||
public DbSet<Client> Clients => Set<Client>();
|
||||
public DbSet<Project> Projects => Set<Project>();
|
||||
public DbSet<ContentItem> ContentItems => Set<ContentItem>();
|
||||
public DbSet<ContentItemRevision> ContentItemRevisions => Set<ContentItemRevision>();
|
||||
public DbSet<Asset> Assets => Set<Asset>();
|
||||
public DbSet<AssetRevision> AssetRevisions => Set<AssetRevision>();
|
||||
public DbSet<Comment> Comments => Set<Comment>();
|
||||
public DbSet<ApprovalRequest> ApprovalRequests => Set<ApprovalRequest>();
|
||||
public DbSet<ApprovalDecision> ApprovalDecisions => Set<ApprovalDecision>();
|
||||
public DbSet<NotificationEvent> NotificationEvents => Set<NotificationEvent>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<Workspace>(workspace =>
|
||||
{
|
||||
workspace.ToTable("Workspaces");
|
||||
workspace.HasKey(x => x.Id);
|
||||
workspace.Property(x => x.Name).HasMaxLength(256).IsRequired();
|
||||
workspace.Property(x => x.Slug).HasMaxLength(128).IsRequired();
|
||||
workspace.Property(x => x.TimeZone).HasMaxLength(128).IsRequired();
|
||||
workspace.Property(x => x.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
workspace.HasIndex(x => x.Slug).IsUnique();
|
||||
workspace.HasIndex(x => x.OwnerUserId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<WorkspaceInvite>(workspaceInvite =>
|
||||
{
|
||||
workspaceInvite.ToTable("WorkspaceInvites");
|
||||
workspaceInvite.HasKey(x => x.Id);
|
||||
workspaceInvite.Property(x => x.Email).HasMaxLength(256).IsRequired();
|
||||
workspaceInvite.Property(x => x.Role).HasMaxLength(64).IsRequired();
|
||||
workspaceInvite.Property(x => x.Status).HasMaxLength(64).IsRequired();
|
||||
workspaceInvite.Property(x => x.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
workspaceInvite.HasIndex(x => x.WorkspaceId);
|
||||
workspaceInvite.HasIndex(x => new { x.WorkspaceId, x.Email, x.Status });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Client>(client =>
|
||||
{
|
||||
client.ToTable("Clients");
|
||||
client.HasKey(x => x.Id);
|
||||
client.Property(x => x.Name).HasMaxLength(256).IsRequired();
|
||||
client.Property(x => x.Status).HasMaxLength(64).IsRequired();
|
||||
client.Property(x => x.PortraitUrl).HasMaxLength(2048);
|
||||
client.Property(x => x.PrimaryContactName).HasMaxLength(256);
|
||||
client.Property(x => x.PrimaryContactEmail).HasMaxLength(256);
|
||||
client.Property(x => x.PrimaryContactPortraitUrl).HasMaxLength(2048);
|
||||
client.Property(x => x.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
client.HasIndex(x => new { x.WorkspaceId, x.Name }).IsUnique();
|
||||
client.HasIndex(x => x.WorkspaceId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Project>(project =>
|
||||
{
|
||||
project.ToTable("Projects");
|
||||
project.HasKey(x => x.Id);
|
||||
project.Property(x => x.Name).HasMaxLength(256).IsRequired();
|
||||
project.Property(x => x.Description).HasMaxLength(4000);
|
||||
project.Property(x => x.Notes).HasMaxLength(4000);
|
||||
project.Property(x => x.Status).HasMaxLength(64).IsRequired();
|
||||
project.Property(x => x.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
project.HasIndex(x => new { x.ClientId, x.Name }).IsUnique();
|
||||
project.HasIndex(x => x.WorkspaceId);
|
||||
project.HasIndex(x => x.ClientId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ContentItem>(contentItem =>
|
||||
{
|
||||
contentItem.ToTable("ContentItems");
|
||||
contentItem.HasKey(x => x.Id);
|
||||
contentItem.Property(x => x.Title).HasMaxLength(256).IsRequired();
|
||||
contentItem.Property(x => x.PublicationMessage).HasMaxLength(4000).IsRequired();
|
||||
contentItem.Property(x => x.PublicationTargets).HasMaxLength(512).IsRequired();
|
||||
contentItem.Property(x => x.Hashtags).HasMaxLength(1024);
|
||||
contentItem.Property(x => x.Status).HasMaxLength(64).IsRequired();
|
||||
contentItem.Property(x => x.CurrentRevisionLabel).HasMaxLength(32).IsRequired();
|
||||
contentItem.Property(x => x.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
contentItem.HasIndex(x => x.WorkspaceId);
|
||||
contentItem.HasIndex(x => x.ClientId);
|
||||
contentItem.HasIndex(x => x.ProjectId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ContentItemRevision>(revision =>
|
||||
{
|
||||
revision.ToTable("ContentItemRevisions");
|
||||
revision.HasKey(x => x.Id);
|
||||
revision.Property(x => x.RevisionLabel).HasMaxLength(32).IsRequired();
|
||||
revision.Property(x => x.Title).HasMaxLength(256).IsRequired();
|
||||
revision.Property(x => x.PublicationMessage).HasMaxLength(4000).IsRequired();
|
||||
revision.Property(x => x.PublicationTargets).HasMaxLength(512).IsRequired();
|
||||
revision.Property(x => x.Hashtags).HasMaxLength(1024);
|
||||
revision.Property(x => x.ChangeSummary).HasMaxLength(1024);
|
||||
revision.Property(x => x.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
revision.HasIndex(x => x.ContentItemId);
|
||||
revision.HasIndex(x => new { x.ContentItemId, x.RevisionNumber }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Asset>(asset =>
|
||||
{
|
||||
asset.ToTable("Assets");
|
||||
asset.HasKey(x => x.Id);
|
||||
asset.Property(x => x.AssetType).HasMaxLength(64).IsRequired();
|
||||
asset.Property(x => x.SourceType).HasMaxLength(64).IsRequired();
|
||||
asset.Property(x => x.DisplayName).HasMaxLength(256).IsRequired();
|
||||
asset.Property(x => x.GoogleDriveFileId).HasMaxLength(256);
|
||||
asset.Property(x => x.GoogleDriveLink).HasMaxLength(2048);
|
||||
asset.Property(x => x.PreviewUrl).HasMaxLength(2048);
|
||||
asset.Property(x => x.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
asset.HasIndex(x => x.WorkspaceId);
|
||||
asset.HasIndex(x => x.ContentItemId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<AssetRevision>(revision =>
|
||||
{
|
||||
revision.ToTable("AssetRevisions");
|
||||
revision.HasKey(x => x.Id);
|
||||
revision.Property(x => x.SourceReference).HasMaxLength(2048).IsRequired();
|
||||
revision.Property(x => x.PreviewUrl).HasMaxLength(2048);
|
||||
revision.Property(x => x.Notes).HasMaxLength(1024);
|
||||
revision.Property(x => x.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
revision.HasIndex(x => x.AssetId);
|
||||
revision.HasIndex(x => new { x.AssetId, x.RevisionNumber }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Comment>(comment =>
|
||||
{
|
||||
comment.ToTable("Comments");
|
||||
comment.HasKey(x => x.Id);
|
||||
comment.Property(x => x.AuthorDisplayName).HasMaxLength(256).IsRequired();
|
||||
comment.Property(x => x.AuthorEmail).HasMaxLength(256).IsRequired();
|
||||
comment.Property(x => x.Body).HasMaxLength(4000).IsRequired();
|
||||
comment.Property(x => x.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
comment.HasIndex(x => x.WorkspaceId);
|
||||
comment.HasIndex(x => x.ContentItemId);
|
||||
comment.HasIndex(x => x.ParentCommentId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ApprovalRequest>(approvalRequest =>
|
||||
{
|
||||
approvalRequest.ToTable("ApprovalRequests");
|
||||
approvalRequest.HasKey(x => x.Id);
|
||||
approvalRequest.Property(x => x.Stage).HasMaxLength(64).IsRequired();
|
||||
approvalRequest.Property(x => x.ReviewerName).HasMaxLength(256).IsRequired();
|
||||
approvalRequest.Property(x => x.ReviewerEmail).HasMaxLength(256).IsRequired();
|
||||
approvalRequest.Property(x => x.State).HasMaxLength(64).IsRequired();
|
||||
approvalRequest.Property(x => x.AccessToken).HasMaxLength(64).IsRequired();
|
||||
approvalRequest.Property(x => x.SentAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
approvalRequest.HasIndex(x => x.WorkspaceId);
|
||||
approvalRequest.HasIndex(x => x.ContentItemId);
|
||||
approvalRequest.HasIndex(x => x.ReviewerEmail);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ApprovalDecision>(approvalDecision =>
|
||||
{
|
||||
approvalDecision.ToTable("ApprovalDecisions");
|
||||
approvalDecision.HasKey(x => x.Id);
|
||||
approvalDecision.Property(x => x.Decision).HasMaxLength(64).IsRequired();
|
||||
approvalDecision.Property(x => x.Comment).HasMaxLength(2048);
|
||||
approvalDecision.Property(x => x.DecidedByName).HasMaxLength(256).IsRequired();
|
||||
approvalDecision.Property(x => x.DecidedByEmail).HasMaxLength(256).IsRequired();
|
||||
approvalDecision.Property(x => x.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
approvalDecision.HasIndex(x => x.ApprovalRequestId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<NotificationEvent>(notificationEvent =>
|
||||
{
|
||||
notificationEvent.ToTable("NotificationEvents");
|
||||
notificationEvent.HasKey(x => x.Id);
|
||||
notificationEvent.Property(x => x.EventType).HasMaxLength(128).IsRequired();
|
||||
notificationEvent.Property(x => x.EntityType).HasMaxLength(128).IsRequired();
|
||||
notificationEvent.Property(x => x.Message).HasMaxLength(1024).IsRequired();
|
||||
notificationEvent.Property(x => x.RecipientEmail).HasMaxLength(256);
|
||||
notificationEvent.Property(x => x.MetadataJson).HasMaxLength(4000);
|
||||
notificationEvent.Property(x => x.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
notificationEvent.HasIndex(x => x.WorkspaceId);
|
||||
notificationEvent.HasIndex(x => x.ContentItemId);
|
||||
notificationEvent.HasIndex(x => x.RecipientUserId);
|
||||
notificationEvent.HasIndex(x => x.CreatedAt);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text;
|
||||
using Hutopy.Modules.Identity.Data;
|
||||
using Socialize.Data;
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Facebook;
|
||||
using Microsoft.AspNetCore.Authentication.Google;
|
||||
@@ -7,7 +8,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Hutopy;
|
||||
namespace Socialize;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
@@ -18,9 +19,10 @@ public static class DependencyInjection
|
||||
services.AddHttpContextAccessor();
|
||||
|
||||
services.AddHealthChecks()
|
||||
.AddDbContextCheck<IdentityDbContext>();
|
||||
.AddDbContextCheck<AppDbContext>();
|
||||
|
||||
services.AddHttpClient();
|
||||
services.AddScoped<AccessScopeService>();
|
||||
|
||||
// Customise default API behaviour
|
||||
services.Configure<ApiBehaviorOptions>(options =>
|
||||
@@ -31,6 +33,27 @@ public static class DependencyInjection
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAppData(
|
||||
this IServiceCollection services,
|
||||
string postgresConnectionString)
|
||||
{
|
||||
services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseNpgsql(postgresConnectionString));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static async Task<IApplicationBuilder> UseAppDataAsync(
|
||||
this IApplicationBuilder app,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using IServiceScope scope = app.ApplicationServices.CreateScope();
|
||||
await using AppDbContext context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await context.Database.EnsureCreatedAsync(cancellationToken);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAuthorizationAndAuthentication(this IServiceCollection services,
|
||||
ConfigurationManager configuration)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
global using FastEndpoints;
|
||||
global using FluentValidation;
|
||||
global using FastEndpoints;
|
||||
global using JetBrains.Annotations;
|
||||
global using Microsoft.EntityFrameworkCore;
|
||||
global using Socialize.Data;
|
||||
global using Socialize.Modules.Approvals.Data;
|
||||
global using Socialize.Modules.Assets.Data;
|
||||
global using Socialize.Modules.Clients.Data;
|
||||
global using Socialize.Modules.Comments.Data;
|
||||
global using Socialize.Modules.ContentItems.Data;
|
||||
global using Socialize.Modules.Identity.Data;
|
||||
global using Socialize.Modules.Notifications.Data;
|
||||
global using Socialize.Modules.Projects.Data;
|
||||
global using Socialize.Modules.Workspaces.Data;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
namespace Hutopy.Infrastructure.BlobStorage.Contracts;
|
||||
namespace Socialize.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
public static class CommonFileNames
|
||||
{
|
||||
public const string ProfilePicture = "profilePicture";
|
||||
public const string LogoPicture = "logoPicture";
|
||||
public const string BannerPicture = "bannerPicture";
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
namespace Hutopy.Infrastructure.BlobStorage.Contracts;
|
||||
namespace Socialize.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
internal static class ContainerNames
|
||||
{
|
||||
public const string Users = "users";
|
||||
public const string Clients = "clients";
|
||||
public const string Creators = "creators";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Hutopy.Infrastructure.BlobStorage.Contracts;
|
||||
namespace Socialize.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
public static class ContentTypes
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Hutopy.Infrastructure.BlobStorage.Contracts;
|
||||
namespace Socialize.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
public interface IBlobStorage
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Hutopy.Infrastructure.BlobStorage.Contracts;
|
||||
namespace Socialize.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
public static class SubDirectoryNames
|
||||
{
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using Azure;
|
||||
using Azure.Storage.Blobs;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
using Hutopy.Infrastructure.BlobStorage.Contracts;
|
||||
using Socialize.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
namespace Hutopy.Infrastructure.BlobStorage.Services;
|
||||
namespace Socialize.Infrastructure.BlobStorage.Services;
|
||||
|
||||
public class AzureBlobStorage : IBlobStorage
|
||||
{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
namespace Hutopy.Infrastructure.Configuration;
|
||||
namespace Socialize.Infrastructure.Configuration;
|
||||
|
||||
public class WebsiteOptions
|
||||
{
|
||||
public const string SectionName = "Website";
|
||||
|
||||
public string FrontendBaseUrl { get; set; } = "https://localhost:5173";
|
||||
public string FrontendBaseUrl { get; set; } = "http://localhost:5173";
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
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;
|
||||
using Socialize.Infrastructure.BlobStorage.Contracts;
|
||||
using Socialize.Infrastructure.BlobStorage.Services;
|
||||
using Socialize.Infrastructure.Configuration;
|
||||
using Socialize.Infrastructure.Emailer.Configuration;
|
||||
using Socialize.Infrastructure.Emailer.Contracts;
|
||||
using Socialize.Infrastructure.Emailer.Services;
|
||||
using Socialize.Infrastructure.Payments.Stripe.Configuration;
|
||||
|
||||
namespace Hutopy.Infrastructure;
|
||||
namespace Socialize.Infrastructure;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
@@ -20,11 +17,6 @@ public static class DependencyInjection
|
||||
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));
|
||||
|
||||
|
||||
633
backend/Infrastructure/Development/DevelopmentSeedExtensions.cs
Normal file
633
backend/Infrastructure/Development/DevelopmentSeedExtensions.cs
Normal file
@@ -0,0 +1,633 @@
|
||||
using System.Security.Claims;
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Contracts;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Socialize.Infrastructure.Development;
|
||||
|
||||
public static class DevelopmentSeedExtensions
|
||||
{
|
||||
private static readonly Guid WorkspaceId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
private static readonly Guid ScopedClientId = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||
private static readonly Guid HiddenClientId = Guid.Parse("22222222-2222-2222-2222-333333333333");
|
||||
private static readonly Guid ScopedProjectId = Guid.Parse("33333333-3333-3333-3333-333333333333");
|
||||
private static readonly Guid HiddenProjectId = Guid.Parse("33333333-3333-3333-3333-444444444444");
|
||||
private static readonly Guid ScopedContentItemId = Guid.Parse("44444444-4444-4444-4444-444444444444");
|
||||
private static readonly Guid HiddenContentItemId = Guid.Parse("44444444-4444-4444-4444-555555555555");
|
||||
private static readonly Guid ScopedAssetId = Guid.Parse("55555555-5555-5555-5555-555555555555");
|
||||
private static readonly Guid ScopedApprovalRequestId = Guid.Parse("66666666-6666-6666-6666-666666666666");
|
||||
private static readonly Guid ClientCommentId = Guid.Parse("77777777-7777-7777-7777-777777777777");
|
||||
private static readonly Guid NotificationId = Guid.Parse("88888888-8888-8888-8888-888888888888");
|
||||
|
||||
public static async Task<IApplicationBuilder> UseDevelopmentSeedAsync(
|
||||
this IApplicationBuilder app,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IHostEnvironment environment = app.ApplicationServices.GetRequiredService<IHostEnvironment>();
|
||||
if (!environment.IsDevelopment())
|
||||
{
|
||||
return app;
|
||||
}
|
||||
|
||||
using IServiceScope scope = app.ApplicationServices.CreateScope();
|
||||
IOptions<DevelopmentSeedOptions> options = scope.ServiceProvider.GetRequiredService<IOptions<DevelopmentSeedOptions>>();
|
||||
if (!options.Value.Enabled)
|
||||
{
|
||||
return app;
|
||||
}
|
||||
|
||||
UserManager userManager = scope.ServiceProvider.GetRequiredService<UserManager>();
|
||||
AppDbContext dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
await RemoveLegacyDevUserAsync(userManager);
|
||||
|
||||
User manager = await EnsureUserAsync(
|
||||
userManager,
|
||||
id: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
||||
username: "manager",
|
||||
email: "manager@socialize.local",
|
||||
password: "manager",
|
||||
alias: "Northstar Manager",
|
||||
firstname: "Morgan",
|
||||
lastname: "Reid",
|
||||
portraitUrl: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=200&q=80",
|
||||
roles: [KnownRoles.Administrator, KnownRoles.Manager, KnownRoles.WorkspaceMember],
|
||||
claims:
|
||||
[
|
||||
new Claim(KnownClaims.WorkspaceScope, WorkspaceId.ToString()),
|
||||
]);
|
||||
|
||||
User clientUser = await EnsureUserAsync(
|
||||
userManager,
|
||||
id: Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
|
||||
username: "client",
|
||||
email: "client@socialize.local",
|
||||
password: "client",
|
||||
alias: "Sofia Martin",
|
||||
firstname: "Sofia",
|
||||
lastname: "Martin",
|
||||
portraitUrl: "https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&w=200&q=80",
|
||||
roles: [KnownRoles.Client, KnownRoles.WorkspaceMember],
|
||||
claims:
|
||||
[
|
||||
new Claim(KnownClaims.WorkspaceScope, WorkspaceId.ToString()),
|
||||
new Claim(KnownClaims.ClientScope, ScopedClientId.ToString()),
|
||||
]);
|
||||
|
||||
User provider = await EnsureUserAsync(
|
||||
userManager,
|
||||
id: Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"),
|
||||
username: "provider",
|
||||
email: "provider@socialize.local",
|
||||
password: "provider",
|
||||
alias: "Alex Studio",
|
||||
firstname: "Alex",
|
||||
lastname: "Studio",
|
||||
portraitUrl: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=200&q=80",
|
||||
roles: [KnownRoles.Provider, KnownRoles.WorkspaceMember],
|
||||
claims:
|
||||
[
|
||||
new Claim(KnownClaims.WorkspaceScope, WorkspaceId.ToString()),
|
||||
new Claim(KnownClaims.ClientScope, ScopedClientId.ToString()),
|
||||
new Claim(KnownClaims.ProjectScope, ScopedProjectId.ToString()),
|
||||
]);
|
||||
|
||||
await EnsureWorkspaceDataAsync(
|
||||
manager.Id,
|
||||
clientUser.Id,
|
||||
provider.Id,
|
||||
dbContext,
|
||||
cancellationToken);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task RemoveLegacyDevUserAsync(UserManager userManager)
|
||||
{
|
||||
User? legacyUser = await userManager.FindByNameAsync("dev")
|
||||
?? await userManager.FindByEmailAsync("dev@socialize.local");
|
||||
|
||||
if (legacyUser is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await userManager.DeleteAsync(legacyUser);
|
||||
}
|
||||
|
||||
private static async Task<User> EnsureUserAsync(
|
||||
UserManager userManager,
|
||||
Guid id,
|
||||
string username,
|
||||
string email,
|
||||
string password,
|
||||
string alias,
|
||||
string firstname,
|
||||
string lastname,
|
||||
string? portraitUrl,
|
||||
IReadOnlyCollection<string> roles,
|
||||
IReadOnlyCollection<Claim> claims)
|
||||
{
|
||||
User? user = await userManager.FindByNameAsync(username)
|
||||
?? await userManager.FindByEmailAsync(email);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
user = new User
|
||||
{
|
||||
Id = id,
|
||||
UserName = username,
|
||||
Email = email,
|
||||
Alias = alias,
|
||||
Firstname = firstname,
|
||||
Lastname = lastname,
|
||||
PortraitUrl = portraitUrl,
|
||||
EmailConfirmed = true,
|
||||
};
|
||||
|
||||
IdentityResult createResult = await userManager.CreateAsync(user, password);
|
||||
if (!createResult.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to seed development user '{username}': {string.Join(", ", createResult.Errors.Select(error => error.Description))}");
|
||||
}
|
||||
}
|
||||
|
||||
user.UserName = username;
|
||||
user.Email = email;
|
||||
user.Alias = alias;
|
||||
user.Firstname = firstname;
|
||||
user.Lastname = lastname;
|
||||
user.PortraitUrl = portraitUrl;
|
||||
user.EmailConfirmed = true;
|
||||
await userManager.UpdateAsync(user);
|
||||
|
||||
if (!await userManager.CheckPasswordAsync(user, password))
|
||||
{
|
||||
string resetToken = await userManager.GeneratePasswordResetTokenAsync(user);
|
||||
IdentityResult passwordResetResult = await userManager.ResetPasswordAsync(user, resetToken, password);
|
||||
if (!passwordResetResult.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to set development password for '{username}': {string.Join(", ", passwordResetResult.Errors.Select(error => error.Description))}");
|
||||
}
|
||||
}
|
||||
|
||||
IList<string> existingRoles = await userManager.GetRolesAsync(user);
|
||||
foreach (string role in roles.Except(existingRoles, StringComparer.Ordinal))
|
||||
{
|
||||
await userManager.AddToRoleAsync(user, role);
|
||||
}
|
||||
|
||||
foreach (string role in existingRoles
|
||||
.Where(role => role is KnownRoles.Manager or KnownRoles.Client or KnownRoles.Provider or KnownRoles.Administrator or KnownRoles.WorkspaceMember)
|
||||
.Except(roles, StringComparer.Ordinal))
|
||||
{
|
||||
await userManager.RemoveFromRoleAsync(user, role);
|
||||
}
|
||||
|
||||
IList<Claim> existingClaims = await userManager.GetClaimsAsync(user);
|
||||
List<Claim> managedClaims = existingClaims
|
||||
.Where(claim => claim.Type is KnownClaims.WorkspaceScope or KnownClaims.ClientScope or KnownClaims.ProjectScope or KnownClaims.Persona)
|
||||
.ToList();
|
||||
|
||||
foreach (Claim claim in managedClaims)
|
||||
{
|
||||
await userManager.RemoveClaimAsync(user, claim);
|
||||
}
|
||||
|
||||
string persona = roles.Contains(KnownRoles.Manager, StringComparer.Ordinal)
|
||||
? KnownRoles.Manager
|
||||
: roles.Contains(KnownRoles.Client, StringComparer.Ordinal)
|
||||
? KnownRoles.Client
|
||||
: roles.Contains(KnownRoles.Provider, StringComparer.Ordinal)
|
||||
? KnownRoles.Provider
|
||||
: KnownRoles.WorkspaceMember;
|
||||
|
||||
foreach (Claim claim in claims.Concat([new Claim(KnownClaims.Persona, persona)]))
|
||||
{
|
||||
await userManager.AddClaimAsync(user, claim);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private static async Task EnsureWorkspaceDataAsync(
|
||||
Guid managerUserId,
|
||||
Guid clientUserId,
|
||||
Guid providerUserId,
|
||||
AppDbContext dbContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Workspace? workspace = await dbContext.Workspaces
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == WorkspaceId, cancellationToken);
|
||||
if (workspace is null)
|
||||
{
|
||||
workspace = new Workspace
|
||||
{
|
||||
Id = WorkspaceId,
|
||||
Name = string.Empty,
|
||||
Slug = string.Empty,
|
||||
TimeZone = string.Empty,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
dbContext.Workspaces.Add(workspace);
|
||||
}
|
||||
|
||||
workspace.Name = "Northstar Studio";
|
||||
workspace.Slug = "northstar-studio";
|
||||
workspace.OwnerUserId = managerUserId;
|
||||
workspace.TimeZone = "America/Montreal";
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await UpsertClientAsync(
|
||||
dbContext,
|
||||
ScopedClientId,
|
||||
"Luma Coffee",
|
||||
"Active",
|
||||
"https://images.unsplash.com/photo-1511920170033-f8396924c348?auto=format&fit=crop&w=200&q=80",
|
||||
"Sofia Martin",
|
||||
"client@socialize.local",
|
||||
WorkspaceId,
|
||||
cancellationToken);
|
||||
await UpsertClientAsync(
|
||||
dbContext,
|
||||
HiddenClientId,
|
||||
"Atlas Bakery",
|
||||
"Active",
|
||||
"https://images.unsplash.com/photo-1509440159596-0249088772ff?auto=format&fit=crop&w=200&q=80",
|
||||
"Nina Cole",
|
||||
"nina@atlasbakery.test",
|
||||
WorkspaceId,
|
||||
cancellationToken);
|
||||
|
||||
await UpsertProjectAsync(
|
||||
dbContext,
|
||||
ScopedProjectId,
|
||||
WorkspaceId,
|
||||
ScopedClientId,
|
||||
"Spring Launch",
|
||||
"In progress",
|
||||
DateTimeOffset.UtcNow.AddDays(1),
|
||||
DateTimeOffset.UtcNow.AddDays(7),
|
||||
"Cross-channel launch campaign for the spring offer.",
|
||||
"Coordinate creative approvals before the final week.",
|
||||
cancellationToken);
|
||||
await UpsertProjectAsync(
|
||||
dbContext,
|
||||
HiddenProjectId,
|
||||
WorkspaceId,
|
||||
HiddenClientId,
|
||||
"Summer Retention",
|
||||
"Planned",
|
||||
DateTimeOffset.UtcNow.AddDays(10),
|
||||
DateTimeOffset.UtcNow.AddDays(16),
|
||||
"Retention campaign aimed at existing subscribers.",
|
||||
"Sequence email and paid social updates together.",
|
||||
cancellationToken);
|
||||
|
||||
await UpsertContentItemAsync(
|
||||
dbContext,
|
||||
ScopedContentItemId,
|
||||
WorkspaceId,
|
||||
ScopedClientId,
|
||||
ScopedProjectId,
|
||||
"Spring launch hero video",
|
||||
"Fresh seasonal menu launch across Instagram and TikTok.",
|
||||
"Instagram Reel, TikTok",
|
||||
"In client review",
|
||||
DateTimeOffset.UtcNow.AddDays(3),
|
||||
"v3",
|
||||
3,
|
||||
cancellationToken);
|
||||
await UpsertContentItemAsync(
|
||||
dbContext,
|
||||
HiddenContentItemId,
|
||||
WorkspaceId,
|
||||
HiddenClientId,
|
||||
HiddenProjectId,
|
||||
"Bakery loyalty carousel",
|
||||
"Reward regular customers with a four-card retention carousel.",
|
||||
"Instagram Carousel",
|
||||
"Draft",
|
||||
DateTimeOffset.UtcNow.AddDays(10),
|
||||
"v1",
|
||||
1,
|
||||
cancellationToken);
|
||||
|
||||
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000001"), ScopedContentItemId, 1, "v1", "Spring launch hero video", "Initial draft for the seasonal menu launch.", "Instagram Reel, TikTok", "Initial concept draft.", providerUserId, DateTimeOffset.UtcNow.AddDays(-5), cancellationToken);
|
||||
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000002"), ScopedContentItemId, 2, "v2", "Spring launch hero video", "Updated hook and transitions after internal review.", "Instagram Reel, TikTok", "Addressed internal pacing feedback.", providerUserId, DateTimeOffset.UtcNow.AddDays(-3), cancellationToken);
|
||||
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000003"), ScopedContentItemId, 3, "v3", "Spring launch hero video", "Fresh seasonal menu launch across Instagram and TikTok.", "Instagram Reel, TikTok", "Client-facing draft after copy cleanup.", providerUserId, DateTimeOffset.UtcNow.AddDays(-1), cancellationToken);
|
||||
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000004"), HiddenContentItemId, 1, "v1", "Bakery loyalty carousel", "Reward regular customers with a four-card retention carousel.", "Instagram Carousel", "First draft.", managerUserId, DateTimeOffset.UtcNow.AddDays(-2), cancellationToken);
|
||||
|
||||
Asset? asset = await dbContext.Assets.SingleOrDefaultAsync(candidate => candidate.Id == ScopedAssetId, cancellationToken);
|
||||
if (asset is null)
|
||||
{
|
||||
asset = new Asset
|
||||
{
|
||||
Id = ScopedAssetId,
|
||||
AssetType = string.Empty,
|
||||
SourceType = string.Empty,
|
||||
DisplayName = string.Empty,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-4),
|
||||
};
|
||||
dbContext.Assets.Add(asset);
|
||||
}
|
||||
asset.WorkspaceId = WorkspaceId;
|
||||
asset.ContentItemId = ScopedContentItemId;
|
||||
asset.AssetType = "Video";
|
||||
asset.SourceType = "GoogleDrive";
|
||||
asset.DisplayName = "Spring launch cut";
|
||||
asset.GoogleDriveFileId = "dev-socialize-demo";
|
||||
asset.GoogleDriveLink = "https://drive.google.com/file/d/dev-socialize-demo/view";
|
||||
asset.PreviewUrl = "https://drive.google.com/thumbnail?id=dev-socialize-demo";
|
||||
asset.CurrentRevisionNumber = 2;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await EnsureAssetRevisionAsync(dbContext, Guid.Parse("55555555-5555-5555-5555-000000000001"), ScopedAssetId, 1, "https://drive.google.com/file/d/dev-socialize-demo-v1/view", "https://drive.google.com/thumbnail?id=dev-socialize-demo-v1", "First uploaded cut from the editor.", providerUserId, DateTimeOffset.UtcNow.AddDays(-4), cancellationToken);
|
||||
await EnsureAssetRevisionAsync(dbContext, Guid.Parse("55555555-5555-5555-5555-000000000002"), ScopedAssetId, 2, "https://drive.google.com/file/d/dev-socialize-demo-v2/view", "https://drive.google.com/thumbnail?id=dev-socialize-demo-v2", "Re-export with pacing changes and updated title card.", providerUserId, DateTimeOffset.UtcNow.AddDays(-2), cancellationToken);
|
||||
|
||||
Comment? comment = await dbContext.Comments.SingleOrDefaultAsync(candidate => candidate.Id == ClientCommentId, cancellationToken);
|
||||
if (comment is null)
|
||||
{
|
||||
comment = new Comment
|
||||
{
|
||||
Id = ClientCommentId,
|
||||
AuthorDisplayName = string.Empty,
|
||||
AuthorEmail = string.Empty,
|
||||
Body = string.Empty,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddHours(-20),
|
||||
};
|
||||
dbContext.Comments.Add(comment);
|
||||
}
|
||||
comment.WorkspaceId = WorkspaceId;
|
||||
comment.ContentItemId = ScopedContentItemId;
|
||||
comment.AuthorUserId = clientUserId;
|
||||
comment.AuthorDisplayName = "Sofia Martin";
|
||||
comment.AuthorEmail = "client@socialize.local";
|
||||
comment.Body = "Please tighten the opening three seconds and make the launch CTA more explicit.";
|
||||
comment.IsResolved = false;
|
||||
comment.ResolvedAt = null;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
ApprovalRequest? approvalRequest = await dbContext.ApprovalRequests.SingleOrDefaultAsync(candidate => candidate.Id == ScopedApprovalRequestId, cancellationToken);
|
||||
if (approvalRequest is null)
|
||||
{
|
||||
approvalRequest = new ApprovalRequest
|
||||
{
|
||||
Id = ScopedApprovalRequestId,
|
||||
Stage = string.Empty,
|
||||
ReviewerName = string.Empty,
|
||||
ReviewerEmail = string.Empty,
|
||||
State = string.Empty,
|
||||
AccessToken = string.Empty,
|
||||
SentAt = DateTimeOffset.UtcNow.AddHours(-12),
|
||||
};
|
||||
dbContext.ApprovalRequests.Add(approvalRequest);
|
||||
}
|
||||
approvalRequest.WorkspaceId = WorkspaceId;
|
||||
approvalRequest.ContentItemId = ScopedContentItemId;
|
||||
approvalRequest.Stage = "Client";
|
||||
approvalRequest.ReviewerName = "Sofia Martin";
|
||||
approvalRequest.ReviewerEmail = "client@socialize.local";
|
||||
approvalRequest.RequestedByUserId = managerUserId;
|
||||
approvalRequest.DueAt = DateTimeOffset.UtcNow.AddDays(1);
|
||||
approvalRequest.State = "Pending";
|
||||
approvalRequest.AccessToken = "seed-client-review-token";
|
||||
approvalRequest.CompletedAt = null;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
NotificationEvent? approvalNotification = await dbContext.NotificationEvents.SingleOrDefaultAsync(candidate => candidate.Id == NotificationId, cancellationToken);
|
||||
if (approvalNotification is null)
|
||||
{
|
||||
approvalNotification = new NotificationEvent
|
||||
{
|
||||
Id = NotificationId,
|
||||
EventType = string.Empty,
|
||||
EntityType = string.Empty,
|
||||
Message = string.Empty,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddHours(-12),
|
||||
};
|
||||
dbContext.NotificationEvents.Add(approvalNotification);
|
||||
}
|
||||
approvalNotification.WorkspaceId = WorkspaceId;
|
||||
approvalNotification.ContentItemId = ScopedContentItemId;
|
||||
approvalNotification.EventType = "approval.requested";
|
||||
approvalNotification.EntityType = "ApprovalRequest";
|
||||
approvalNotification.EntityId = ScopedApprovalRequestId;
|
||||
approvalNotification.Message = "Approval requested from Sofia Martin for Spring launch hero video.";
|
||||
approvalNotification.RecipientEmail = "client@socialize.local";
|
||||
approvalNotification.MetadataJson = """{"stage":"Client"}""";
|
||||
approvalNotification.ReadAt = null;
|
||||
Guid commentNotificationId = Guid.Parse("88888888-8888-8888-8888-000000000002");
|
||||
NotificationEvent? commentNotification = await dbContext.NotificationEvents.SingleOrDefaultAsync(candidate => candidate.Id == commentNotificationId, cancellationToken);
|
||||
if (commentNotification is null)
|
||||
{
|
||||
commentNotification = new NotificationEvent
|
||||
{
|
||||
Id = commentNotificationId,
|
||||
EventType = string.Empty,
|
||||
EntityType = string.Empty,
|
||||
Message = string.Empty,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddHours(-20),
|
||||
};
|
||||
dbContext.NotificationEvents.Add(commentNotification);
|
||||
}
|
||||
commentNotification.WorkspaceId = WorkspaceId;
|
||||
commentNotification.ContentItemId = ScopedContentItemId;
|
||||
commentNotification.EventType = "comment.created";
|
||||
commentNotification.EntityType = "Comment";
|
||||
commentNotification.EntityId = ClientCommentId;
|
||||
commentNotification.Message = "Sofia Martin commented on Spring launch hero video.";
|
||||
commentNotification.RecipientUserId = managerUserId;
|
||||
commentNotification.RecipientEmail = "manager@socialize.local";
|
||||
commentNotification.MetadataJson = null;
|
||||
commentNotification.ReadAt = null;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task UpsertClientAsync(
|
||||
AppDbContext dbContext,
|
||||
Guid id,
|
||||
string name,
|
||||
string status,
|
||||
string portraitUrl,
|
||||
string primaryContactName,
|
||||
string primaryContactEmail,
|
||||
Guid workspaceId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Client? client = await dbContext.Clients.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
|
||||
if (client is null)
|
||||
{
|
||||
client = new Client
|
||||
{
|
||||
Id = id,
|
||||
Name = string.Empty,
|
||||
Status = string.Empty,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
dbContext.Clients.Add(client);
|
||||
}
|
||||
client.WorkspaceId = workspaceId;
|
||||
client.Name = name;
|
||||
client.Status = status;
|
||||
client.PortraitUrl = portraitUrl;
|
||||
client.PrimaryContactName = primaryContactName;
|
||||
client.PrimaryContactEmail = primaryContactEmail;
|
||||
client.PrimaryContactPortraitUrl = null;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task UpsertProjectAsync(
|
||||
AppDbContext dbContext,
|
||||
Guid id,
|
||||
Guid workspaceId,
|
||||
Guid clientId,
|
||||
string name,
|
||||
string status,
|
||||
DateTimeOffset startDate,
|
||||
DateTimeOffset endDate,
|
||||
string? description,
|
||||
string? notes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Project? project = await dbContext.Projects.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
|
||||
if (project is null)
|
||||
{
|
||||
project = new Project
|
||||
{
|
||||
Id = id,
|
||||
Name = string.Empty,
|
||||
Status = string.Empty,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
dbContext.Projects.Add(project);
|
||||
}
|
||||
project.WorkspaceId = workspaceId;
|
||||
project.ClientId = clientId;
|
||||
project.Name = name;
|
||||
project.Description = description;
|
||||
project.Notes = notes;
|
||||
project.Status = status;
|
||||
project.StartDate = startDate;
|
||||
project.EndDate = endDate;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task UpsertContentItemAsync(
|
||||
AppDbContext dbContext,
|
||||
Guid id,
|
||||
Guid workspaceId,
|
||||
Guid clientId,
|
||||
Guid projectId,
|
||||
string title,
|
||||
string publicationMessage,
|
||||
string publicationTargets,
|
||||
string status,
|
||||
DateTimeOffset? dueDate,
|
||||
string currentRevisionLabel,
|
||||
int currentRevisionNumber,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ContentItem? item = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
item = new ContentItem
|
||||
{
|
||||
Id = id,
|
||||
Title = string.Empty,
|
||||
PublicationMessage = string.Empty,
|
||||
PublicationTargets = string.Empty,
|
||||
Status = string.Empty,
|
||||
CurrentRevisionLabel = string.Empty,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
dbContext.ContentItems.Add(item);
|
||||
}
|
||||
item.WorkspaceId = workspaceId;
|
||||
item.ClientId = clientId;
|
||||
item.ProjectId = projectId;
|
||||
item.Title = title;
|
||||
item.PublicationMessage = publicationMessage;
|
||||
item.PublicationTargets = publicationTargets;
|
||||
item.Status = status;
|
||||
item.DueDate = dueDate;
|
||||
item.CurrentRevisionLabel = currentRevisionLabel;
|
||||
item.CurrentRevisionNumber = currentRevisionNumber;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task EnsureRevisionAsync(
|
||||
AppDbContext dbContext,
|
||||
Guid id,
|
||||
Guid contentItemId,
|
||||
int revisionNumber,
|
||||
string revisionLabel,
|
||||
string title,
|
||||
string publicationMessage,
|
||||
string publicationTargets,
|
||||
string changeSummary,
|
||||
Guid createdByUserId,
|
||||
DateTimeOffset createdAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ContentItemRevision? revision = await dbContext.ContentItemRevisions.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
|
||||
if (revision is null)
|
||||
{
|
||||
revision = new ContentItemRevision
|
||||
{
|
||||
Id = id,
|
||||
RevisionLabel = string.Empty,
|
||||
Title = string.Empty,
|
||||
PublicationMessage = string.Empty,
|
||||
PublicationTargets = string.Empty,
|
||||
CreatedAt = createdAt,
|
||||
};
|
||||
dbContext.ContentItemRevisions.Add(revision);
|
||||
}
|
||||
revision.ContentItemId = contentItemId;
|
||||
revision.RevisionNumber = revisionNumber;
|
||||
revision.RevisionLabel = revisionLabel;
|
||||
revision.Title = title;
|
||||
revision.PublicationMessage = publicationMessage;
|
||||
revision.PublicationTargets = publicationTargets;
|
||||
revision.ChangeSummary = changeSummary;
|
||||
revision.CreatedByUserId = createdByUserId;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task EnsureAssetRevisionAsync(
|
||||
AppDbContext dbContext,
|
||||
Guid id,
|
||||
Guid assetId,
|
||||
int revisionNumber,
|
||||
string sourceReference,
|
||||
string? previewUrl,
|
||||
string? notes,
|
||||
Guid createdByUserId,
|
||||
DateTimeOffset createdAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
AssetRevision? revision = await dbContext.AssetRevisions.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
|
||||
if (revision is null)
|
||||
{
|
||||
revision = new AssetRevision
|
||||
{
|
||||
Id = id,
|
||||
SourceReference = string.Empty,
|
||||
CreatedAt = createdAt,
|
||||
};
|
||||
dbContext.AssetRevisions.Add(revision);
|
||||
}
|
||||
revision.AssetId = assetId;
|
||||
revision.RevisionNumber = revisionNumber;
|
||||
revision.SourceReference = sourceReference;
|
||||
revision.PreviewUrl = previewUrl;
|
||||
revision.Notes = notes;
|
||||
revision.CreatedByUserId = createdByUserId;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Socialize.Infrastructure.Development;
|
||||
|
||||
public record DevelopmentSeedOptions
|
||||
{
|
||||
public const string SectionName = "DevelopmentSeed";
|
||||
|
||||
public bool Enabled { get; init; } = true;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Hutopy.Infrastructure.Emailer.Configuration;
|
||||
namespace Socialize.Infrastructure.Emailer.Configuration;
|
||||
|
||||
public class EmailerOptions
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Hutopy.Infrastructure.Emailer.Contracts;
|
||||
namespace Socialize.Infrastructure.Emailer.Contracts;
|
||||
|
||||
public interface IEmailSender
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using Hutopy.Infrastructure.Emailer.Contracts;
|
||||
using Socialize.Infrastructure.Emailer.Contracts;
|
||||
|
||||
namespace Hutopy.Infrastructure.Emailer.Services;
|
||||
namespace Socialize.Infrastructure.Emailer.Services;
|
||||
|
||||
public class LoggerEmailSender(ILogger<IEmailSender> logger)
|
||||
: IEmailSender
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using Hutopy.Infrastructure.Emailer.Configuration;
|
||||
using Hutopy.Infrastructure.Emailer.Contracts;
|
||||
using Socialize.Infrastructure.Emailer.Configuration;
|
||||
using Socialize.Infrastructure.Emailer.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PostmarkDotNet;
|
||||
|
||||
namespace Hutopy.Infrastructure.Emailer.Services;
|
||||
namespace Socialize.Infrastructure.Emailer.Services;
|
||||
|
||||
public class PostmarkEmailSender : IEmailSender
|
||||
{
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Hutopy.Infrastructure.Emailer.Configuration;
|
||||
using Hutopy.Infrastructure.Emailer.Contracts;
|
||||
using Socialize.Infrastructure.Emailer.Configuration;
|
||||
using Socialize.Infrastructure.Emailer.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Hutopy.Infrastructure.Emailer.Services;
|
||||
namespace Socialize.Infrastructure.Emailer.Services;
|
||||
|
||||
public class ResendEmailSender : IEmailSender
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Hutopy.Infrastructure.Payments.Stripe.Configuration;
|
||||
namespace Socialize.Infrastructure.Payments.Stripe.Configuration;
|
||||
|
||||
public class StripeOptions
|
||||
{
|
||||
@@ -10,5 +10,5 @@ public class StripeOptions
|
||||
|
||||
[Required] public required string WebhookSecret { get; init; }
|
||||
|
||||
[Required] [Range(0, 1)] public required decimal HutopyRate { get; init; }
|
||||
[Required] [Range(0, 1)] public required decimal SocializeRate { get; init; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
56
backend/Infrastructure/Security/AccessScopeService.cs
Normal file
56
backend/Infrastructure/Security/AccessScopeService.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System.Security.Claims;
|
||||
using Socialize.Modules.Identity.Contracts;
|
||||
|
||||
namespace Socialize.Infrastructure.Security;
|
||||
|
||||
public sealed class AccessScopeService
|
||||
{
|
||||
public bool IsManager(ClaimsPrincipal user)
|
||||
{
|
||||
return user.IsInRole(KnownRoles.Administrator) || user.IsInRole(KnownRoles.Manager);
|
||||
}
|
||||
|
||||
public bool IsProvider(ClaimsPrincipal user)
|
||||
{
|
||||
return user.IsInRole(KnownRoles.Provider);
|
||||
}
|
||||
|
||||
public bool IsClient(ClaimsPrincipal user)
|
||||
{
|
||||
return user.IsInRole(KnownRoles.Client);
|
||||
}
|
||||
|
||||
public bool CanAccessWorkspace(ClaimsPrincipal user, Guid workspaceId)
|
||||
{
|
||||
return IsManager(user) || user.GetWorkspaceScopeIds().Contains(workspaceId);
|
||||
}
|
||||
|
||||
public bool CanManageWorkspace(ClaimsPrincipal user, Guid workspaceId)
|
||||
{
|
||||
return IsManager(user) && CanAccessWorkspace(user, workspaceId);
|
||||
}
|
||||
|
||||
public bool CanAccessClient(ClaimsPrincipal user, Guid workspaceId, Guid clientId)
|
||||
{
|
||||
return IsManager(user)
|
||||
|| (CanAccessWorkspace(user, workspaceId) && user.GetClientScopeIds().Contains(clientId));
|
||||
}
|
||||
|
||||
public bool CanAccessProject(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid projectId)
|
||||
{
|
||||
return IsManager(user)
|
||||
|| (CanAccessClient(user, workspaceId, clientId) && user.GetProjectScopeIds().Contains(projectId));
|
||||
}
|
||||
|
||||
public bool CanContributeToProject(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid projectId)
|
||||
{
|
||||
return IsManager(user) || (IsProvider(user) && CanAccessProject(user, workspaceId, clientId, projectId));
|
||||
}
|
||||
|
||||
public bool CanReviewContent(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid projectId)
|
||||
{
|
||||
return IsManager(user)
|
||||
|| IsProvider(user) && CanAccessProject(user, workspaceId, clientId, projectId)
|
||||
|| IsClient(user) && CanAccessClient(user, workspaceId, clientId);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,38 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Hutopy.Infrastructure.Security;
|
||||
namespace Socialize.Infrastructure.Security;
|
||||
|
||||
public static class ClaimsPrincipalExtensions
|
||||
{
|
||||
public static IReadOnlyCollection<Guid> GetScopeIds(this ClaimsPrincipal claims, string key)
|
||||
{
|
||||
return claims.FindAll(key)
|
||||
.Select(claim => Guid.TryParse(claim.Value, out Guid value) ? value : Guid.Empty)
|
||||
.Where(value => value != Guid.Empty)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public static IReadOnlyCollection<Guid> GetWorkspaceScopeIds(this ClaimsPrincipal claims)
|
||||
{
|
||||
return claims.GetScopeIds(KnownClaims.WorkspaceScope);
|
||||
}
|
||||
|
||||
public static IReadOnlyCollection<Guid> GetClientScopeIds(this ClaimsPrincipal claims)
|
||||
{
|
||||
return claims.GetScopeIds(KnownClaims.ClientScope);
|
||||
}
|
||||
|
||||
public static IReadOnlyCollection<Guid> GetProjectScopeIds(this ClaimsPrincipal claims)
|
||||
{
|
||||
return claims.GetScopeIds(KnownClaims.ProjectScope);
|
||||
}
|
||||
|
||||
public static string? GetPersona(this ClaimsPrincipal claims)
|
||||
{
|
||||
return (string?)claims.GetClaim<string?>(KnownClaims.Persona);
|
||||
}
|
||||
|
||||
public static Guid GetUserId(this ClaimsPrincipal claims)
|
||||
{
|
||||
return (Guid)claims.GetRequiredClaim<Guid>(ClaimTypes.NameIdentifier);
|
||||
|
||||
@@ -3,7 +3,7 @@ using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Hutopy.Infrastructure.Security;
|
||||
namespace Socialize.Infrastructure.Security;
|
||||
|
||||
public static class JwtTokenHelper
|
||||
{
|
||||
@@ -17,7 +17,9 @@ public static class JwtTokenHelper
|
||||
string? alias,
|
||||
string firstname,
|
||||
string lastname,
|
||||
string? portraitUrl)
|
||||
string? portraitUrl,
|
||||
IEnumerable<string> roles,
|
||||
IEnumerable<Claim> additionalClaims)
|
||||
{
|
||||
SymmetricSecurityKey securityKey = new(Encoding.UTF8.GetBytes(key));
|
||||
SigningCredentials credentials = new(securityKey, SecurityAlgorithms.HmacSha256);
|
||||
@@ -40,6 +42,18 @@ public static class JwtTokenHelper
|
||||
claims.Add(new Claim(KnownClaims.PortraitUrl, portraitUrl));
|
||||
}
|
||||
|
||||
foreach (string role in roles.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||
}
|
||||
|
||||
foreach (Claim claim in additionalClaims
|
||||
.Where(claim => !string.IsNullOrWhiteSpace(claim.Type) && !string.IsNullOrWhiteSpace(claim.Value))
|
||||
.DistinctBy(claim => $"{claim.Type}:{claim.Value}"))
|
||||
{
|
||||
claims.Add(claim);
|
||||
}
|
||||
|
||||
JwtSecurityToken token = new(
|
||||
issuer,
|
||||
audience,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
namespace Hutopy.Infrastructure.Security;
|
||||
namespace Socialize.Infrastructure.Security;
|
||||
|
||||
public static class KnownClaims
|
||||
{
|
||||
public const string Alias = "alias";
|
||||
public const string PortraitUrl = "portraitUrl";
|
||||
public const string WorkspaceScope = "workspace";
|
||||
public const string ClientScope = "client";
|
||||
public const string ProjectScope = "project";
|
||||
public const string Persona = "persona";
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Hutopy.Infrastructure.Security;
|
||||
namespace Socialize.Infrastructure.Security;
|
||||
|
||||
public class MissingClaimException(
|
||||
string claimName)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Hutopy.Infrastructure.Security;
|
||||
namespace Socialize.Infrastructure.Security;
|
||||
|
||||
// If we need to add special characters we can alternate between 2 pools.
|
||||
public static class PasswordGenerator
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Hutopy.Infrastructure.Security;
|
||||
namespace Socialize.Infrastructure.Security;
|
||||
|
||||
public static class RefreshTokenGenerator
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Hutopy.Infrastructure.YouTube;
|
||||
namespace Socialize.Infrastructure.YouTube;
|
||||
|
||||
public static class YouTubeUrlHelper
|
||||
{
|
||||
|
||||
942
backend/Migrations/20260423061407_Initial.Designer.cs
generated
Normal file
942
backend/Migrations/20260423061407_Initial.Designer.cs
generated
Normal file
@@ -0,0 +1,942 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Socialize.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Socialize.Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260423061407_Initial")]
|
||||
partial class Initial
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.0")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
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", (string)null);
|
||||
});
|
||||
|
||||
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", (string)null);
|
||||
});
|
||||
|
||||
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", (string)null);
|
||||
});
|
||||
|
||||
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", (string)null);
|
||||
});
|
||||
|
||||
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", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Approvals.Data.ApprovalDecision", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ApprovalRequestId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Comment")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("DecidedByEmail")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("DecidedByName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<Guid?>("DecidedByUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Decision")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ApprovalRequestId");
|
||||
|
||||
b.ToTable("ApprovalDecisions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Approvals.Data.ApprovalRequest", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AccessToken")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset?>("CompletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("ContentItemId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("DueAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("RequestedByUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ReviewerEmail")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("ReviewerName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<DateTimeOffset>("SentAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Stage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ContentItemId");
|
||||
|
||||
b.HasIndex("ReviewerEmail");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("ApprovalRequests", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Assets.Data.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AssetType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("ContentItemId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<int>("CurrentRevisionNumber")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("GoogleDriveFileId")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("GoogleDriveLink")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("PreviewUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("SourceType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ContentItemId");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Assets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Assets.Data.AssetRevision", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid?>("CreatedByUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<string>("PreviewUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<int>("RevisionNumber")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("SourceReference")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId");
|
||||
|
||||
b.HasIndex("AssetId", "RevisionNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AssetRevisions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Clients.Data.Client", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PortraitUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("PrimaryContactEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PrimaryContactName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PrimaryContactPortraitUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.HasIndex("WorkspaceId", "Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Clients", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Comments.Data.Comment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AuthorDisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("AuthorEmail")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<Guid>("AuthorUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<Guid>("ContentItemId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<bool>("IsResolved")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<Guid?>("ParentCommentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("ResolvedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ContentItemId");
|
||||
|
||||
b.HasIndex("ParentCommentId");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Comments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.ContentItems.Data.ContentItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ClientId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("CurrentRevisionLabel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<int>("CurrentRevisionNumber")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("DueDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Hashtags")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PublicationMessage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<string>("PublicationTargets")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClientId");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("ContentItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.ContentItems.Data.ContentItemRevision", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ChangeSummary")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<Guid>("ContentItemId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid?>("CreatedByUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Hashtags")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<string>("PublicationMessage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<string>("PublicationTargets")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("RevisionLabel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<int>("RevisionNumber")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ContentItemId");
|
||||
|
||||
b.HasIndex("ContentItemId", "RevisionNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ContentItemRevisions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.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", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.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", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Notifications.Data.NotificationEvent", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ContentItemId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("EntityType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<string>("MetadataJson")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReadAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("RecipientEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<Guid?>("RecipientUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ContentItemId");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("RecipientUserId");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("NotificationEvents", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Projects.Data.Project", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ClientId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<DateTimeOffset>("EndDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<DateTimeOffset>("StartDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClientId");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.HasIndex("ClientId", "Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Projects", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Workspaces.Data.Workspace", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("TimeZone")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Workspaces", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Workspaces.Data.WorkspaceInvite", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<Guid>("InvitedByUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.HasIndex("WorkspaceId", "Email", "Status");
|
||||
|
||||
b.ToTable("WorkspaceInvites", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Socialize.Modules.Identity.Data.Role", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Socialize.Modules.Identity.Data.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Socialize.Modules.Identity.Data.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Socialize.Modules.Identity.Data.Role", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Socialize.Modules.Identity.Data.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Socialize.Modules.Identity.Data.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
657
backend/Migrations/20260423061407_Initial.cs
Normal file
657
backend/Migrations/20260423061407_Initial.cs
Normal file
@@ -0,0 +1,657 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Socialize.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Initial : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ApprovalDecisions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ApprovalRequestId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Decision = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
Comment = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
DecidedByUserId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
DecidedByName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
DecidedByEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ApprovalDecisions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ApprovalRequests",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ContentItemId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Stage = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
ReviewerName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
ReviewerEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
RequestedByUserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
DueAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
State = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
AccessToken = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
SentAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
|
||||
CompletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ApprovalRequests", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetRoles",
|
||||
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",
|
||||
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: "AssetRevisions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
AssetId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
RevisionNumber = table.Column<int>(type: "integer", nullable: false),
|
||||
SourceReference = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
|
||||
PreviewUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
Notes = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||
CreatedByUserId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AssetRevisions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Assets",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ContentItemId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
AssetType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
SourceType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
DisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
GoogleDriveFileId = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
GoogleDriveLink = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
PreviewUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
CurrentRevisionNumber = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Assets", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Clients",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
PortraitUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
PrimaryContactName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
PrimaryContactEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
PrimaryContactPortraitUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Clients", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Comments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ContentItemId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ParentCommentId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
AuthorUserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
AuthorDisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
AuthorEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
Body = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: false),
|
||||
IsResolved = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
|
||||
ResolvedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Comments", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ContentItemRevisions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ContentItemId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
RevisionNumber = table.Column<int>(type: "integer", nullable: false),
|
||||
RevisionLabel = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
Title = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
PublicationMessage = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: false),
|
||||
PublicationTargets = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
||||
Hashtags = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||
ChangeSummary = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||
CreatedByUserId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ContentItemRevisions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ContentItems",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ClientId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ProjectId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Title = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
PublicationMessage = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: false),
|
||||
PublicationTargets = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
||||
Hashtags = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||
Status = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
DueDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
CurrentRevisionLabel = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
CurrentRevisionNumber = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ContentItems", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NotificationEvents",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ContentItemId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
EventType = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
EntityType = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
EntityId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Message = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
RecipientUserId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
RecipientEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
MetadataJson = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
|
||||
ReadAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NotificationEvents", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Projects",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ClientId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
|
||||
Notes = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
|
||||
Status = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
StartDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
EndDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Projects", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "WorkspaceInvites",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
Role = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
InvitedByUserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_WorkspaceInvites", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Workspaces",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", 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),
|
||||
OwnerUserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
TimeZone = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Workspaces", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetRoleClaims",
|
||||
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,
|
||||
principalTable: "AspNetRoles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserClaims",
|
||||
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,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserLogins",
|
||||
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,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserRoles",
|
||||
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,
|
||||
principalTable: "AspNetRoles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserTokens",
|
||||
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,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ApprovalDecisions_ApprovalRequestId",
|
||||
table: "ApprovalDecisions",
|
||||
column: "ApprovalRequestId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ApprovalRequests_ContentItemId",
|
||||
table: "ApprovalRequests",
|
||||
column: "ContentItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ApprovalRequests_ReviewerEmail",
|
||||
table: "ApprovalRequests",
|
||||
column: "ReviewerEmail");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ApprovalRequests_WorkspaceId",
|
||||
table: "ApprovalRequests",
|
||||
column: "WorkspaceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetRoleClaims_RoleId",
|
||||
table: "AspNetRoleClaims",
|
||||
column: "RoleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "RoleNameIndex",
|
||||
table: "AspNetRoles",
|
||||
column: "NormalizedName",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetUserClaims_UserId",
|
||||
table: "AspNetUserClaims",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetUserLogins_UserId",
|
||||
table: "AspNetUserLogins",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetUserRoles_RoleId",
|
||||
table: "AspNetUserRoles",
|
||||
column: "RoleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "EmailIndex",
|
||||
table: "AspNetUsers",
|
||||
column: "NormalizedEmail");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UserNameIndex",
|
||||
table: "AspNetUsers",
|
||||
column: "NormalizedUserName",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AssetRevisions_AssetId",
|
||||
table: "AssetRevisions",
|
||||
column: "AssetId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AssetRevisions_AssetId_RevisionNumber",
|
||||
table: "AssetRevisions",
|
||||
columns: new[] { "AssetId", "RevisionNumber" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_ContentItemId",
|
||||
table: "Assets",
|
||||
column: "ContentItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_WorkspaceId",
|
||||
table: "Assets",
|
||||
column: "WorkspaceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Clients_WorkspaceId",
|
||||
table: "Clients",
|
||||
column: "WorkspaceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Clients_WorkspaceId_Name",
|
||||
table: "Clients",
|
||||
columns: new[] { "WorkspaceId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Comments_ContentItemId",
|
||||
table: "Comments",
|
||||
column: "ContentItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Comments_ParentCommentId",
|
||||
table: "Comments",
|
||||
column: "ParentCommentId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Comments_WorkspaceId",
|
||||
table: "Comments",
|
||||
column: "WorkspaceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ContentItemRevisions_ContentItemId",
|
||||
table: "ContentItemRevisions",
|
||||
column: "ContentItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ContentItemRevisions_ContentItemId_RevisionNumber",
|
||||
table: "ContentItemRevisions",
|
||||
columns: new[] { "ContentItemId", "RevisionNumber" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ContentItems_ClientId",
|
||||
table: "ContentItems",
|
||||
column: "ClientId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ContentItems_ProjectId",
|
||||
table: "ContentItems",
|
||||
column: "ProjectId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ContentItems_WorkspaceId",
|
||||
table: "ContentItems",
|
||||
column: "WorkspaceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationEvents_ContentItemId",
|
||||
table: "NotificationEvents",
|
||||
column: "ContentItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationEvents_CreatedAt",
|
||||
table: "NotificationEvents",
|
||||
column: "CreatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationEvents_RecipientUserId",
|
||||
table: "NotificationEvents",
|
||||
column: "RecipientUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationEvents_WorkspaceId",
|
||||
table: "NotificationEvents",
|
||||
column: "WorkspaceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Projects_ClientId",
|
||||
table: "Projects",
|
||||
column: "ClientId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Projects_ClientId_Name",
|
||||
table: "Projects",
|
||||
columns: new[] { "ClientId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Projects_WorkspaceId",
|
||||
table: "Projects",
|
||||
column: "WorkspaceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WorkspaceInvites_WorkspaceId",
|
||||
table: "WorkspaceInvites",
|
||||
column: "WorkspaceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WorkspaceInvites_WorkspaceId_Email_Status",
|
||||
table: "WorkspaceInvites",
|
||||
columns: new[] { "WorkspaceId", "Email", "Status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Workspaces_OwnerUserId",
|
||||
table: "Workspaces",
|
||||
column: "OwnerUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Workspaces_Slug",
|
||||
table: "Workspaces",
|
||||
column: "Slug",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ApprovalDecisions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ApprovalRequests");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetRoleClaims");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUserClaims");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUserLogins");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUserRoles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUserTokens");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AssetRevisions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Assets");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Clients");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Comments");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ContentItemRevisions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ContentItems");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "NotificationEvents");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Projects");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "WorkspaceInvites");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Workspaces");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetRoles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
||||
939
backend/Migrations/AppDbContextModelSnapshot.cs
Normal file
939
backend/Migrations/AppDbContextModelSnapshot.cs
Normal file
@@ -0,0 +1,939 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Socialize.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Socialize.Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
partial class AppDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.0")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
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", (string)null);
|
||||
});
|
||||
|
||||
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", (string)null);
|
||||
});
|
||||
|
||||
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", (string)null);
|
||||
});
|
||||
|
||||
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", (string)null);
|
||||
});
|
||||
|
||||
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", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Approvals.Data.ApprovalDecision", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ApprovalRequestId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Comment")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("DecidedByEmail")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("DecidedByName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<Guid?>("DecidedByUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Decision")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ApprovalRequestId");
|
||||
|
||||
b.ToTable("ApprovalDecisions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Approvals.Data.ApprovalRequest", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AccessToken")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset?>("CompletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("ContentItemId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("DueAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("RequestedByUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ReviewerEmail")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("ReviewerName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<DateTimeOffset>("SentAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Stage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ContentItemId");
|
||||
|
||||
b.HasIndex("ReviewerEmail");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("ApprovalRequests", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Assets.Data.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AssetType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("ContentItemId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<int>("CurrentRevisionNumber")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("GoogleDriveFileId")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("GoogleDriveLink")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("PreviewUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("SourceType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ContentItemId");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Assets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Assets.Data.AssetRevision", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid?>("CreatedByUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<string>("PreviewUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<int>("RevisionNumber")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("SourceReference")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId");
|
||||
|
||||
b.HasIndex("AssetId", "RevisionNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AssetRevisions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Clients.Data.Client", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PortraitUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("PrimaryContactEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PrimaryContactName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PrimaryContactPortraitUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.HasIndex("WorkspaceId", "Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Clients", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Comments.Data.Comment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AuthorDisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("AuthorEmail")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<Guid>("AuthorUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<Guid>("ContentItemId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<bool>("IsResolved")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<Guid?>("ParentCommentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("ResolvedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ContentItemId");
|
||||
|
||||
b.HasIndex("ParentCommentId");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Comments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.ContentItems.Data.ContentItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ClientId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("CurrentRevisionLabel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<int>("CurrentRevisionNumber")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("DueDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Hashtags")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PublicationMessage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<string>("PublicationTargets")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClientId");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("ContentItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.ContentItems.Data.ContentItemRevision", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ChangeSummary")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<Guid>("ContentItemId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid?>("CreatedByUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Hashtags")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<string>("PublicationMessage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<string>("PublicationTargets")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("RevisionLabel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<int>("RevisionNumber")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ContentItemId");
|
||||
|
||||
b.HasIndex("ContentItemId", "RevisionNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ContentItemRevisions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.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", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.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", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Notifications.Data.NotificationEvent", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ContentItemId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("EntityType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<string>("MetadataJson")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReadAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("RecipientEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<Guid?>("RecipientUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ContentItemId");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("RecipientUserId");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("NotificationEvents", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Projects.Data.Project", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ClientId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<DateTimeOffset>("EndDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<DateTimeOffset>("StartDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClientId");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.HasIndex("ClientId", "Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Projects", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Workspaces.Data.Workspace", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("TimeZone")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Workspaces", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Modules.Workspaces.Data.WorkspaceInvite", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<Guid>("InvitedByUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.HasIndex("WorkspaceId", "Email", "Status");
|
||||
|
||||
b.ToTable("WorkspaceInvites", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Socialize.Modules.Identity.Data.Role", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Socialize.Modules.Identity.Data.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Socialize.Modules.Identity.Data.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Socialize.Modules.Identity.Data.Role", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Socialize.Modules.Identity.Data.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("Socialize.Modules.Identity.Data.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
13
backend/Modules/Approvals/Data/ApprovalDecision.cs
Normal file
13
backend/Modules/Approvals/Data/ApprovalDecision.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Socialize.Modules.Approvals.Data;
|
||||
|
||||
public class ApprovalDecision
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid ApprovalRequestId { get; set; }
|
||||
public required string Decision { get; set; }
|
||||
public string? Comment { get; set; }
|
||||
public Guid? DecidedByUserId { get; set; }
|
||||
public required string DecidedByName { get; set; }
|
||||
public required string DecidedByEmail { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
17
backend/Modules/Approvals/Data/ApprovalRequest.cs
Normal file
17
backend/Modules/Approvals/Data/ApprovalRequest.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace Socialize.Modules.Approvals.Data;
|
||||
|
||||
public class ApprovalRequest
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid ContentItemId { get; set; }
|
||||
public required string Stage { get; set; }
|
||||
public required string ReviewerName { get; set; }
|
||||
public required string ReviewerEmail { get; set; }
|
||||
public Guid RequestedByUserId { get; set; }
|
||||
public DateTimeOffset? DueAt { get; set; }
|
||||
public required string State { get; set; }
|
||||
public required string AccessToken { get; set; }
|
||||
public DateTimeOffset SentAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
}
|
||||
12
backend/Modules/Approvals/DependencyInjection.cs
Normal file
12
backend/Modules/Approvals/DependencyInjection.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Socialize.Modules.Approvals.Data;
|
||||
|
||||
namespace Socialize.Modules.Approvals;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddApprovalsModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
118
backend/Modules/Approvals/Handlers/CreateApprovalRequest.cs
Normal file
118
backend/Modules/Approvals/Handlers/CreateApprovalRequest.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using System.Security.Cryptography;
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Notifications.Contracts;
|
||||
|
||||
namespace Socialize.Modules.Approvals.Handlers;
|
||||
|
||||
public record CreateApprovalRequestRequest(
|
||||
Guid WorkspaceId,
|
||||
Guid ContentItemId,
|
||||
string Stage,
|
||||
string ReviewerName,
|
||||
string ReviewerEmail,
|
||||
DateTimeOffset? DueAt);
|
||||
|
||||
public class CreateApprovalRequestRequestValidator
|
||||
: Validator<CreateApprovalRequestRequest>
|
||||
{
|
||||
public CreateApprovalRequestRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.WorkspaceId).NotEmpty();
|
||||
RuleFor(x => x.ContentItemId).NotEmpty();
|
||||
RuleFor(x => x.Stage).NotEmpty().MaximumLength(64);
|
||||
RuleFor(x => x.ReviewerName).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.ReviewerEmail).NotEmpty().MaximumLength(256).EmailAddress();
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateApprovalRequestHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: Endpoint<CreateApprovalRequestRequest, ApprovalRequestDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/approvals");
|
||||
Options(o => o.WithTags("Approvals"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateApprovalRequestRequest request, CancellationToken ct)
|
||||
{
|
||||
ContentItem? contentItem = await dbContext.ContentItems
|
||||
.SingleOrDefaultAsync(
|
||||
candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId,
|
||||
ct);
|
||||
|
||||
if (contentItem is null)
|
||||
{
|
||||
AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanManageWorkspace(User, contentItem.WorkspaceId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
ApprovalRequest approval = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = request.WorkspaceId,
|
||||
ContentItemId = request.ContentItemId,
|
||||
Stage = request.Stage.Trim(),
|
||||
ReviewerName = request.ReviewerName.Trim(),
|
||||
ReviewerEmail = request.ReviewerEmail.Trim(),
|
||||
RequestedByUserId = User.GetUserId(),
|
||||
DueAt = request.DueAt,
|
||||
State = "Pending",
|
||||
AccessToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(),
|
||||
SentAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
dbContext.ApprovalRequests.Add(approval);
|
||||
|
||||
if (approval.Stage == "Internal")
|
||||
{
|
||||
contentItem.Status = "In internal review";
|
||||
}
|
||||
else if (approval.Stage == "Client")
|
||||
{
|
||||
contentItem.Status = "In client review";
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
approval.WorkspaceId,
|
||||
approval.ContentItemId,
|
||||
"approval.requested",
|
||||
"ApprovalRequest",
|
||||
approval.Id,
|
||||
$"Approval requested from {approval.ReviewerName} for {contentItem.Title}.",
|
||||
null,
|
||||
approval.ReviewerEmail,
|
||||
$$"""{"stage":"{{approval.Stage}}","accessToken":"{{approval.AccessToken}}"}"""),
|
||||
ct);
|
||||
|
||||
ApprovalRequestDto dto = new(
|
||||
approval.Id,
|
||||
approval.WorkspaceId,
|
||||
approval.ContentItemId,
|
||||
approval.Stage,
|
||||
approval.ReviewerName,
|
||||
approval.ReviewerEmail,
|
||||
approval.RequestedByUserId,
|
||||
approval.DueAt,
|
||||
approval.State,
|
||||
approval.AccessToken,
|
||||
approval.SentAt,
|
||||
approval.CompletedAt,
|
||||
[]);
|
||||
|
||||
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
||||
}
|
||||
}
|
||||
117
backend/Modules/Approvals/Handlers/GetApprovals.cs
Normal file
117
backend/Modules/Approvals/Handlers/GetApprovals.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
|
||||
namespace Socialize.Modules.Approvals.Handlers;
|
||||
|
||||
public record GetApprovalsRequest(Guid ContentItemId);
|
||||
|
||||
public record ApprovalDecisionDto(
|
||||
Guid Id,
|
||||
Guid ApprovalRequestId,
|
||||
string Decision,
|
||||
string? Comment,
|
||||
Guid? DecidedByUserId,
|
||||
string DecidedByName,
|
||||
string DecidedByEmail,
|
||||
string? DecidedByPortraitUrl,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public record ApprovalRequestDto(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
Guid ContentItemId,
|
||||
string Stage,
|
||||
string ReviewerName,
|
||||
string ReviewerEmail,
|
||||
Guid RequestedByUserId,
|
||||
DateTimeOffset? DueAt,
|
||||
string State,
|
||||
string AccessToken,
|
||||
DateTimeOffset SentAt,
|
||||
DateTimeOffset? CompletedAt,
|
||||
IReadOnlyCollection<ApprovalDecisionDto> Decisions);
|
||||
|
||||
public class GetApprovalsHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<GetApprovalsRequest, IReadOnlyCollection<ApprovalRequestDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/approvals");
|
||||
Options(o => o.WithTags("Approvals"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetApprovalsRequest request, CancellationToken ct)
|
||||
{
|
||||
ContentItem? item = await dbContext.ContentItems
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId, ct);
|
||||
if (item is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
List<ApprovalRequest> approvals = await dbContext.ApprovalRequests
|
||||
.Where(approval => approval.ContentItemId == request.ContentItemId)
|
||||
.OrderByDescending(approval => approval.SentAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
List<Guid> approvalIds = approvals
|
||||
.Select(approval => approval.Id)
|
||||
.ToList();
|
||||
|
||||
List<ApprovalDecision> decisions = await dbContext.ApprovalDecisions
|
||||
.Where(decision => approvalIds.Contains(decision.ApprovalRequestId))
|
||||
.OrderByDescending(decision => decision.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
List<Guid> decidedByUserIds = decisions
|
||||
.Where(decision => decision.DecidedByUserId.HasValue)
|
||||
.Select(decision => decision.DecidedByUserId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
Dictionary<Guid, string?> decisionPortraits = await dbContext.Users
|
||||
.Where(user => decidedByUserIds.Contains(user.Id))
|
||||
.ToDictionaryAsync(user => user.Id, user => user.PortraitUrl, ct);
|
||||
|
||||
List<ApprovalRequestDto> dtos = approvals
|
||||
.Select(approval => new ApprovalRequestDto(
|
||||
approval.Id,
|
||||
approval.WorkspaceId,
|
||||
approval.ContentItemId,
|
||||
approval.Stage,
|
||||
approval.ReviewerName,
|
||||
approval.ReviewerEmail,
|
||||
approval.RequestedByUserId,
|
||||
approval.DueAt,
|
||||
approval.State,
|
||||
approval.AccessToken,
|
||||
approval.SentAt,
|
||||
approval.CompletedAt,
|
||||
decisions
|
||||
.Where(decision => decision.ApprovalRequestId == approval.Id)
|
||||
.Select(decision => new ApprovalDecisionDto(
|
||||
decision.Id,
|
||||
decision.ApprovalRequestId,
|
||||
decision.Decision,
|
||||
decision.Comment,
|
||||
decision.DecidedByUserId,
|
||||
decision.DecidedByName,
|
||||
decision.DecidedByEmail,
|
||||
decision.DecidedByUserId.HasValue
|
||||
? decisionPortraits.GetValueOrDefault(decision.DecidedByUserId.Value)
|
||||
: null,
|
||||
decision.CreatedAt))
|
||||
.ToList()))
|
||||
.ToList();
|
||||
|
||||
await SendOkAsync(dtos, ct);
|
||||
}
|
||||
}
|
||||
169
backend/Modules/Approvals/Handlers/SubmitApprovalDecision.cs
Normal file
169
backend/Modules/Approvals/Handlers/SubmitApprovalDecision.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Notifications.Contracts;
|
||||
|
||||
namespace Socialize.Modules.Approvals.Handlers;
|
||||
|
||||
public record SubmitApprovalDecisionRequest(
|
||||
string Decision,
|
||||
string? Comment,
|
||||
string? ReviewerName,
|
||||
string? ReviewerEmail);
|
||||
|
||||
public class SubmitApprovalDecisionRequestValidator
|
||||
: Validator<SubmitApprovalDecisionRequest>
|
||||
{
|
||||
public SubmitApprovalDecisionRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Decision).NotEmpty().MaximumLength(64);
|
||||
RuleFor(x => x.Comment).MaximumLength(2048);
|
||||
RuleFor(x => x.ReviewerName).MaximumLength(256);
|
||||
RuleFor(x => x.ReviewerEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ReviewerEmail));
|
||||
}
|
||||
}
|
||||
|
||||
public class SubmitApprovalDecisionHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: Endpoint<SubmitApprovalDecisionRequest, ApprovalRequestDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/approvals/{id}/decisions");
|
||||
AllowAnonymous();
|
||||
Options(o => o.WithTags("Approvals"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(SubmitApprovalDecisionRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
|
||||
ApprovalRequest? approval = await dbContext.ApprovalRequests.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
if (approval is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
ContentItem? contentItem = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == approval.ContentItemId, ct);
|
||||
if (contentItem is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (User?.Identity?.IsAuthenticated == true &&
|
||||
!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string normalizedDecision = request.Decision.Trim();
|
||||
string decidedByName = User?.Identity?.IsAuthenticated == true
|
||||
? User.GetAlias() ?? User.GetName()
|
||||
: string.IsNullOrWhiteSpace(request.ReviewerName) ? approval.ReviewerName : request.ReviewerName.Trim();
|
||||
string decidedByEmail = User?.Identity?.IsAuthenticated == true
|
||||
? User.GetEmail()
|
||||
: string.IsNullOrWhiteSpace(request.ReviewerEmail) ? approval.ReviewerEmail : request.ReviewerEmail.Trim();
|
||||
|
||||
ApprovalDecision decision = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalRequestId = approval.Id,
|
||||
Decision = normalizedDecision,
|
||||
Comment = string.IsNullOrWhiteSpace(request.Comment) ? null : request.Comment.Trim(),
|
||||
DecidedByUserId = User?.Identity?.IsAuthenticated == true ? User.GetUserId() : null,
|
||||
DecidedByName = decidedByName,
|
||||
DecidedByEmail = decidedByEmail,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
approval.State = normalizedDecision;
|
||||
approval.CompletedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
if (approval.Stage == "Internal")
|
||||
{
|
||||
contentItem.Status = normalizedDecision switch
|
||||
{
|
||||
"Approved" => "Ready for client review",
|
||||
"Changes requested" => "Changes requested internally",
|
||||
"Rejected" => "Rejected",
|
||||
_ => contentItem.Status,
|
||||
};
|
||||
}
|
||||
else if (approval.Stage == "Client")
|
||||
{
|
||||
contentItem.Status = normalizedDecision switch
|
||||
{
|
||||
"Approved" => "Approved",
|
||||
"Changes requested" => "Changes requested by client",
|
||||
"Rejected" => "Rejected",
|
||||
_ => contentItem.Status,
|
||||
};
|
||||
}
|
||||
|
||||
dbContext.ApprovalDecisions.Add(decision);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
approval.WorkspaceId,
|
||||
approval.ContentItemId,
|
||||
"approval.decision.recorded",
|
||||
"ApprovalDecision",
|
||||
decision.Id,
|
||||
$"{decidedByName} recorded {normalizedDecision} for {contentItem.Title}.",
|
||||
null,
|
||||
decidedByEmail,
|
||||
$$"""{"stage":"{{approval.Stage}}","status":"{{contentItem.Status}}"}"""),
|
||||
ct);
|
||||
|
||||
List<ApprovalDecision> decisions = await dbContext.ApprovalDecisions
|
||||
.Where(candidate => candidate.ApprovalRequestId == approval.Id)
|
||||
.OrderByDescending(candidate => candidate.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
List<Guid> decidedByUserIds = decisions
|
||||
.Where(candidate => candidate.DecidedByUserId.HasValue)
|
||||
.Select(candidate => candidate.DecidedByUserId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
Dictionary<Guid, string?> decisionPortraits = await dbContext.Users
|
||||
.Where(user => decidedByUserIds.Contains(user.Id))
|
||||
.ToDictionaryAsync(user => user.Id, user => user.PortraitUrl, ct);
|
||||
|
||||
List<ApprovalDecisionDto> decisionDtos = decisions
|
||||
.Select(candidate => new ApprovalDecisionDto(
|
||||
candidate.Id,
|
||||
candidate.ApprovalRequestId,
|
||||
candidate.Decision,
|
||||
candidate.Comment,
|
||||
candidate.DecidedByUserId,
|
||||
candidate.DecidedByName,
|
||||
candidate.DecidedByEmail,
|
||||
candidate.DecidedByUserId.HasValue
|
||||
? decisionPortraits.GetValueOrDefault(candidate.DecidedByUserId.Value)
|
||||
: null,
|
||||
candidate.CreatedAt))
|
||||
.ToList();
|
||||
|
||||
ApprovalRequestDto dto = new(
|
||||
approval.Id,
|
||||
approval.WorkspaceId,
|
||||
approval.ContentItemId,
|
||||
approval.Stage,
|
||||
approval.ReviewerName,
|
||||
approval.ReviewerEmail,
|
||||
approval.RequestedByUserId,
|
||||
approval.DueAt,
|
||||
approval.State,
|
||||
approval.AccessToken,
|
||||
approval.SentAt,
|
||||
approval.CompletedAt,
|
||||
decisionDtos);
|
||||
|
||||
await SendOkAsync(dto, ct);
|
||||
}
|
||||
}
|
||||
16
backend/Modules/Assets/Data/Asset.cs
Normal file
16
backend/Modules/Assets/Data/Asset.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace Socialize.Modules.Assets.Data;
|
||||
|
||||
public class Asset
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid ContentItemId { get; set; }
|
||||
public required string AssetType { get; set; }
|
||||
public required string SourceType { get; set; }
|
||||
public required string DisplayName { get; set; }
|
||||
public string? GoogleDriveFileId { get; set; }
|
||||
public string? GoogleDriveLink { get; set; }
|
||||
public string? PreviewUrl { get; set; }
|
||||
public int CurrentRevisionNumber { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
13
backend/Modules/Assets/Data/AssetRevision.cs
Normal file
13
backend/Modules/Assets/Data/AssetRevision.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Socialize.Modules.Assets.Data;
|
||||
|
||||
public class AssetRevision
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid AssetId { get; set; }
|
||||
public int RevisionNumber { get; set; }
|
||||
public required string SourceReference { get; set; }
|
||||
public string? PreviewUrl { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public Guid? CreatedByUserId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
12
backend/Modules/Assets/DependencyInjection.cs
Normal file
12
backend/Modules/Assets/DependencyInjection.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Socialize.Modules.Assets.Data;
|
||||
|
||||
namespace Socialize.Modules.Assets;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddAssetsModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
102
backend/Modules/Assets/Handlers/CreateAssetRevision.cs
Normal file
102
backend/Modules/Assets/Handlers/CreateAssetRevision.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Notifications.Contracts;
|
||||
|
||||
namespace Socialize.Modules.Assets.Handlers;
|
||||
|
||||
public record CreateAssetRevisionRequest(
|
||||
string SourceReference,
|
||||
string? PreviewUrl,
|
||||
string? Notes);
|
||||
|
||||
public class CreateAssetRevisionRequestValidator
|
||||
: Validator<CreateAssetRevisionRequest>
|
||||
{
|
||||
public CreateAssetRevisionRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.SourceReference).NotEmpty().MaximumLength(2048);
|
||||
RuleFor(x => x.PreviewUrl).MaximumLength(2048);
|
||||
RuleFor(x => x.Notes).MaximumLength(1024);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateAssetRevisionHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: Endpoint<CreateAssetRevisionRequest, AssetRevisionDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/assets/{id}/revisions");
|
||||
Options(o => o.WithTags("Assets"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateAssetRevisionRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
|
||||
Asset? asset = await dbContext.Assets.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
if (asset is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
ContentItem? contentItem = await dbContext.ContentItems
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == asset.ContentItemId, ct);
|
||||
|
||||
if (contentItem is not null &&
|
||||
!accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
int revisionNumber = asset.CurrentRevisionNumber + 1;
|
||||
asset.CurrentRevisionNumber = revisionNumber;
|
||||
asset.PreviewUrl = string.IsNullOrWhiteSpace(request.PreviewUrl) ? asset.PreviewUrl : request.PreviewUrl.Trim();
|
||||
|
||||
AssetRevision revision = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AssetId = asset.Id,
|
||||
RevisionNumber = revisionNumber,
|
||||
SourceReference = request.SourceReference.Trim(),
|
||||
PreviewUrl = string.IsNullOrWhiteSpace(request.PreviewUrl) ? null : request.PreviewUrl.Trim(),
|
||||
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(),
|
||||
CreatedByUserId = User.GetUserId(),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
dbContext.AssetRevisions.Add(revision);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
if (contentItem is not null)
|
||||
{
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
asset.WorkspaceId,
|
||||
asset.ContentItemId,
|
||||
"asset.revision.created",
|
||||
"AssetRevision",
|
||||
revision.Id,
|
||||
$"A new asset revision was added to {asset.DisplayName}.",
|
||||
User.GetUserId(),
|
||||
User.GetEmail(),
|
||||
$$"""{"revisionNumber":"{{revisionNumber}}"}"""),
|
||||
ct);
|
||||
}
|
||||
|
||||
AssetRevisionDto dto = new(
|
||||
revision.Id,
|
||||
revision.AssetId,
|
||||
revision.RevisionNumber,
|
||||
revision.SourceReference,
|
||||
revision.PreviewUrl,
|
||||
revision.Notes,
|
||||
revision.CreatedByUserId,
|
||||
revision.CreatedAt);
|
||||
|
||||
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
||||
}
|
||||
}
|
||||
130
backend/Modules/Assets/Handlers/CreateGoogleDriveAsset.cs
Normal file
130
backend/Modules/Assets/Handlers/CreateGoogleDriveAsset.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Notifications.Contracts;
|
||||
|
||||
namespace Socialize.Modules.Assets.Handlers;
|
||||
|
||||
public record CreateGoogleDriveAssetRequest(
|
||||
Guid WorkspaceId,
|
||||
Guid ContentItemId,
|
||||
string AssetType,
|
||||
string DisplayName,
|
||||
string GoogleDriveFileId,
|
||||
string GoogleDriveLink,
|
||||
string? PreviewUrl);
|
||||
|
||||
public class CreateGoogleDriveAssetRequestValidator
|
||||
: Validator<CreateGoogleDriveAssetRequest>
|
||||
{
|
||||
public CreateGoogleDriveAssetRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.WorkspaceId).NotEmpty();
|
||||
RuleFor(x => x.ContentItemId).NotEmpty();
|
||||
RuleFor(x => x.AssetType).NotEmpty().MaximumLength(64);
|
||||
RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.GoogleDriveFileId).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.GoogleDriveLink).NotEmpty().MaximumLength(2048);
|
||||
RuleFor(x => x.PreviewUrl).MaximumLength(2048);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateGoogleDriveAssetHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: Endpoint<CreateGoogleDriveAssetRequest, AssetDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/assets/google-drive");
|
||||
Options(o => o.WithTags("Assets"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateGoogleDriveAssetRequest request, CancellationToken ct)
|
||||
{
|
||||
ContentItem? contentItem = await dbContext.ContentItems
|
||||
.SingleOrDefaultAsync(
|
||||
candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId,
|
||||
ct);
|
||||
|
||||
if (contentItem is null)
|
||||
{
|
||||
AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Asset asset = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = request.WorkspaceId,
|
||||
ContentItemId = request.ContentItemId,
|
||||
AssetType = request.AssetType.Trim(),
|
||||
SourceType = "GoogleDrive",
|
||||
DisplayName = request.DisplayName.Trim(),
|
||||
GoogleDriveFileId = request.GoogleDriveFileId.Trim(),
|
||||
GoogleDriveLink = request.GoogleDriveLink.Trim(),
|
||||
PreviewUrl = string.IsNullOrWhiteSpace(request.PreviewUrl) ? null : request.PreviewUrl.Trim(),
|
||||
CurrentRevisionNumber = 1,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
AssetRevision revision = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AssetId = asset.Id,
|
||||
RevisionNumber = 1,
|
||||
SourceReference = asset.GoogleDriveLink,
|
||||
PreviewUrl = asset.PreviewUrl,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
dbContext.Assets.Add(asset);
|
||||
dbContext.AssetRevisions.Add(revision);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
asset.WorkspaceId,
|
||||
asset.ContentItemId,
|
||||
"asset.google-drive-linked",
|
||||
"Asset",
|
||||
asset.Id,
|
||||
$"Google Drive asset {asset.DisplayName} was linked to {contentItem.Title}.",
|
||||
null,
|
||||
null,
|
||||
$$"""{"googleDriveFileId":"{{asset.GoogleDriveFileId}}"}"""),
|
||||
ct);
|
||||
|
||||
AssetDto dto = new(
|
||||
asset.Id,
|
||||
asset.WorkspaceId,
|
||||
asset.ContentItemId,
|
||||
asset.AssetType,
|
||||
asset.SourceType,
|
||||
asset.DisplayName,
|
||||
asset.GoogleDriveFileId,
|
||||
asset.GoogleDriveLink,
|
||||
asset.PreviewUrl,
|
||||
asset.CurrentRevisionNumber,
|
||||
asset.CreatedAt,
|
||||
[
|
||||
new AssetRevisionDto(
|
||||
revision.Id,
|
||||
revision.AssetId,
|
||||
revision.RevisionNumber,
|
||||
revision.SourceReference,
|
||||
revision.PreviewUrl,
|
||||
revision.Notes,
|
||||
revision.CreatedByUserId,
|
||||
revision.CreatedAt)
|
||||
]);
|
||||
|
||||
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
||||
}
|
||||
}
|
||||
89
backend/Modules/Assets/Handlers/GetAssets.cs
Normal file
89
backend/Modules/Assets/Handlers/GetAssets.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
namespace Socialize.Modules.Assets.Handlers;
|
||||
|
||||
public record GetAssetsRequest(Guid ContentItemId);
|
||||
|
||||
public record AssetRevisionDto(
|
||||
Guid Id,
|
||||
Guid AssetId,
|
||||
int RevisionNumber,
|
||||
string SourceReference,
|
||||
string? PreviewUrl,
|
||||
string? Notes,
|
||||
Guid? CreatedByUserId,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public record AssetDto(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
Guid ContentItemId,
|
||||
string AssetType,
|
||||
string SourceType,
|
||||
string DisplayName,
|
||||
string? GoogleDriveFileId,
|
||||
string? GoogleDriveLink,
|
||||
string? PreviewUrl,
|
||||
int CurrentRevisionNumber,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyCollection<AssetRevisionDto> Revisions);
|
||||
|
||||
public class GetAssetsHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<GetAssetsRequest, IReadOnlyCollection<AssetDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/assets");
|
||||
Options(o => o.WithTags("Assets"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetAssetsRequest request, CancellationToken ct)
|
||||
{
|
||||
ContentItem? item = await dbContext.ContentItems
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId, ct);
|
||||
if (item is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
List<AssetDto> assets = await dbContext.Assets
|
||||
.Where(asset => asset.ContentItemId == request.ContentItemId)
|
||||
.OrderBy(asset => asset.DisplayName)
|
||||
.Select(asset => new AssetDto(
|
||||
asset.Id,
|
||||
asset.WorkspaceId,
|
||||
asset.ContentItemId,
|
||||
asset.AssetType,
|
||||
asset.SourceType,
|
||||
asset.DisplayName,
|
||||
asset.GoogleDriveFileId,
|
||||
asset.GoogleDriveLink,
|
||||
asset.PreviewUrl,
|
||||
asset.CurrentRevisionNumber,
|
||||
asset.CreatedAt,
|
||||
dbContext.AssetRevisions
|
||||
.Where(revision => revision.AssetId == asset.Id)
|
||||
.OrderByDescending(revision => revision.RevisionNumber)
|
||||
.Select(revision => new AssetRevisionDto(
|
||||
revision.Id,
|
||||
revision.AssetId,
|
||||
revision.RevisionNumber,
|
||||
revision.SourceReference,
|
||||
revision.PreviewUrl,
|
||||
revision.Notes,
|
||||
revision.CreatedByUserId,
|
||||
revision.CreatedAt))
|
||||
.ToList()))
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(assets, ct);
|
||||
}
|
||||
}
|
||||
14
backend/Modules/Clients/Data/Client.cs
Normal file
14
backend/Modules/Clients/Data/Client.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace Socialize.Modules.Clients.Data;
|
||||
|
||||
public class Client
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string Status { get; set; }
|
||||
public string? PortraitUrl { get; set; }
|
||||
public string? PrimaryContactName { get; set; }
|
||||
public string? PrimaryContactEmail { get; set; }
|
||||
public string? PrimaryContactPortraitUrl { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
12
backend/Modules/Clients/DependencyInjection.cs
Normal file
12
backend/Modules/Clients/DependencyInjection.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Socialize.Modules.Clients.Data;
|
||||
|
||||
namespace Socialize.Modules.Clients;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddClientsModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
65
backend/Modules/Clients/Handlers/ChangeClientPortrait.cs
Normal file
65
backend/Modules/Clients/Handlers/ChangeClientPortrait.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using Socialize.Infrastructure.BlobStorage.Contracts;
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Clients.Data;
|
||||
|
||||
namespace Socialize.Modules.Clients.Handlers;
|
||||
|
||||
public record ChangeClientPortraitRequest(
|
||||
IFormFile File);
|
||||
|
||||
public record ChangeClientPortraitResponse(
|
||||
string BlobUrl);
|
||||
|
||||
public sealed class ChangeClientPortraitRequestValidator : Validator<ChangeClientPortraitRequest>
|
||||
{
|
||||
public ChangeClientPortraitRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.File)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public class ChangeClientPortraitHandler(
|
||||
AppDbContext clientsDbContext,
|
||||
IBlobStorage blobStorage,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<ChangeClientPortraitRequest, ChangeClientPortraitResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/clients/{id}/portrait");
|
||||
Options(o => o.WithTags("Clients"));
|
||||
AllowFileUploads();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ChangeClientPortraitRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
|
||||
Client? client = await clientsDbContext.Clients.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
if (client is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanManageWorkspace(User, client.WorkspaceId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string blobUrl = await blobStorage.UploadFileAsync(
|
||||
ContainerNames.Clients,
|
||||
$"{client.Id}/{SubDirectoryNames.Profile}/{CommonFileNames.LogoPicture}",
|
||||
request.File.OpenReadStream(),
|
||||
request.File.ContentType,
|
||||
ct);
|
||||
|
||||
client.PortraitUrl = blobUrl;
|
||||
await clientsDbContext.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(new ChangeClientPortraitResponse(blobUrl), ct);
|
||||
}
|
||||
}
|
||||
101
backend/Modules/Clients/Handlers/CreateClient.cs
Normal file
101
backend/Modules/Clients/Handlers/CreateClient.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
namespace Socialize.Modules.Clients.Handlers;
|
||||
|
||||
public record CreateClientRequest(
|
||||
Guid WorkspaceId,
|
||||
string Name,
|
||||
string? PortraitUrl,
|
||||
string? PrimaryContactName,
|
||||
string? PrimaryContactEmail,
|
||||
string? PrimaryContactPortraitUrl);
|
||||
|
||||
public class CreateClientRequestValidator
|
||||
: Validator<CreateClientRequest>
|
||||
{
|
||||
public CreateClientRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.WorkspaceId).NotEmpty();
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.PortraitUrl).MaximumLength(2048);
|
||||
RuleFor(x => x.PrimaryContactName).MaximumLength(256);
|
||||
RuleFor(x => x.PrimaryContactEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.PrimaryContactEmail));
|
||||
RuleFor(x => x.PrimaryContactPortraitUrl).MaximumLength(2048);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateClientHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<CreateClientRequest, ClientDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/clients");
|
||||
Options(o => o.WithTags("Clients"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateClientRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!accessScopeService.CanManageWorkspace(User, request.WorkspaceId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
bool workspaceExists = await dbContext.Workspaces
|
||||
.AnyAsync(workspace => workspace.Id == request.WorkspaceId, ct);
|
||||
|
||||
if (!workspaceExists)
|
||||
{
|
||||
AddError(request => request.WorkspaceId, "The selected workspace does not exist.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string normalizedName = request.Name.Trim();
|
||||
string? normalizedPortraitUrl = request.PortraitUrl?.Trim();
|
||||
string? normalizedPrimaryContactName = request.PrimaryContactName?.Trim();
|
||||
string? normalizedPrimaryContactEmail = request.PrimaryContactEmail?.Trim();
|
||||
string? normalizedPrimaryContactPortraitUrl = request.PrimaryContactPortraitUrl?.Trim();
|
||||
|
||||
bool duplicateClient = await dbContext.Clients
|
||||
.AnyAsync(
|
||||
client => client.WorkspaceId == request.WorkspaceId && client.Name == normalizedName,
|
||||
ct);
|
||||
|
||||
if (duplicateClient)
|
||||
{
|
||||
AddError(request => request.Name, "A client with this name already exists in the active workspace.");
|
||||
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Client client = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = request.WorkspaceId,
|
||||
Name = normalizedName,
|
||||
Status = "Active",
|
||||
PortraitUrl = string.IsNullOrWhiteSpace(normalizedPortraitUrl) ? null : normalizedPortraitUrl,
|
||||
PrimaryContactName = string.IsNullOrWhiteSpace(normalizedPrimaryContactName) ? null : normalizedPrimaryContactName,
|
||||
PrimaryContactEmail = string.IsNullOrWhiteSpace(normalizedPrimaryContactEmail) ? null : normalizedPrimaryContactEmail,
|
||||
PrimaryContactPortraitUrl = string.IsNullOrWhiteSpace(normalizedPrimaryContactPortraitUrl) ? null : normalizedPrimaryContactPortraitUrl,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
dbContext.Clients.Add(client);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
ClientDto dto = new(
|
||||
client.Id,
|
||||
client.WorkspaceId,
|
||||
client.Name,
|
||||
client.Status,
|
||||
client.PortraitUrl,
|
||||
client.PrimaryContactName,
|
||||
client.PrimaryContactEmail,
|
||||
client.PrimaryContactPortraitUrl);
|
||||
|
||||
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
||||
}
|
||||
}
|
||||
73
backend/Modules/Clients/Handlers/GetClients.cs
Normal file
73
backend/Modules/Clients/Handlers/GetClients.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Clients.Data;
|
||||
|
||||
namespace Socialize.Modules.Clients.Handlers;
|
||||
|
||||
public record GetClientsRequest(Guid? WorkspaceId);
|
||||
|
||||
public record ClientDto(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
string Name,
|
||||
string Status,
|
||||
string? PortraitUrl,
|
||||
string? PrimaryContactName,
|
||||
string? PrimaryContactEmail,
|
||||
string? PrimaryContactPortraitUrl);
|
||||
|
||||
public class GetClientsHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<GetClientsRequest, IReadOnlyCollection<ClientDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/clients");
|
||||
Options(o => o.WithTags("Clients"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetClientsRequest request, CancellationToken ct)
|
||||
{
|
||||
IQueryable<Client> query = dbContext.Clients.AsQueryable();
|
||||
|
||||
if (accessScopeService.IsManager(User))
|
||||
{
|
||||
if (request.WorkspaceId.HasValue)
|
||||
{
|
||||
query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
|
||||
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
|
||||
|
||||
query = query.Where(client => workspaceScopeIds.Contains(client.WorkspaceId));
|
||||
|
||||
if (clientScopeIds.Count > 0)
|
||||
{
|
||||
query = query.Where(client => clientScopeIds.Contains(client.Id));
|
||||
}
|
||||
|
||||
if (request.WorkspaceId.HasValue)
|
||||
{
|
||||
query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value);
|
||||
}
|
||||
}
|
||||
|
||||
List<ClientDto> clients = await query
|
||||
.OrderBy(client => client.Name)
|
||||
.Select(client => new ClientDto(
|
||||
client.Id,
|
||||
client.WorkspaceId,
|
||||
client.Name,
|
||||
client.Status,
|
||||
client.PortraitUrl,
|
||||
client.PrimaryContactName,
|
||||
client.PrimaryContactEmail,
|
||||
client.PrimaryContactPortraitUrl))
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(clients, ct);
|
||||
}
|
||||
}
|
||||
98
backend/Modules/Clients/Handlers/UpdateClient.cs
Normal file
98
backend/Modules/Clients/Handlers/UpdateClient.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Clients.Data;
|
||||
|
||||
namespace Socialize.Modules.Clients.Handlers;
|
||||
|
||||
public record UpdateClientRequest(
|
||||
string Name,
|
||||
string? PortraitUrl,
|
||||
string Status,
|
||||
string? PrimaryContactName,
|
||||
string? PrimaryContactEmail,
|
||||
string? PrimaryContactPortraitUrl);
|
||||
|
||||
public class UpdateClientRequestValidator
|
||||
: Validator<UpdateClientRequest>
|
||||
{
|
||||
public UpdateClientRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.PortraitUrl).MaximumLength(2048);
|
||||
RuleFor(x => x.Status).NotEmpty().MaximumLength(64);
|
||||
RuleFor(x => x.PrimaryContactName).MaximumLength(256);
|
||||
RuleFor(x => x.PrimaryContactEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.PrimaryContactEmail));
|
||||
RuleFor(x => x.PrimaryContactPortraitUrl).MaximumLength(2048);
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateClientHandler(
|
||||
AppDbContext clientsDbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<UpdateClientRequest, ClientDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/clients/{id}");
|
||||
Options(o => o.WithTags("Clients"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(UpdateClientRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
|
||||
Client? client = await clientsDbContext.Clients.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
if (client is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanManageWorkspace(User, client.WorkspaceId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string normalizedName = request.Name.Trim();
|
||||
string normalizedStatus = request.Status.Trim();
|
||||
string? normalizedPortraitUrl = request.PortraitUrl?.Trim();
|
||||
string? normalizedPrimaryContactName = request.PrimaryContactName?.Trim();
|
||||
string? normalizedPrimaryContactEmail = request.PrimaryContactEmail?.Trim();
|
||||
string? normalizedPrimaryContactPortraitUrl = request.PrimaryContactPortraitUrl?.Trim();
|
||||
|
||||
bool duplicateClient = await clientsDbContext.Clients
|
||||
.AnyAsync(
|
||||
candidate => candidate.Id != id
|
||||
&& candidate.WorkspaceId == client.WorkspaceId
|
||||
&& candidate.Name == normalizedName,
|
||||
ct);
|
||||
|
||||
if (duplicateClient)
|
||||
{
|
||||
AddError(request => request.Name, "A client with this name already exists in the active workspace.");
|
||||
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
client.Name = normalizedName;
|
||||
client.Status = normalizedStatus;
|
||||
client.PortraitUrl = string.IsNullOrWhiteSpace(normalizedPortraitUrl) ? null : normalizedPortraitUrl;
|
||||
client.PrimaryContactName = string.IsNullOrWhiteSpace(normalizedPrimaryContactName) ? null : normalizedPrimaryContactName;
|
||||
client.PrimaryContactEmail = string.IsNullOrWhiteSpace(normalizedPrimaryContactEmail) ? null : normalizedPrimaryContactEmail;
|
||||
client.PrimaryContactPortraitUrl = string.IsNullOrWhiteSpace(normalizedPrimaryContactPortraitUrl) ? null : normalizedPrimaryContactPortraitUrl;
|
||||
|
||||
await clientsDbContext.SaveChangesAsync(ct);
|
||||
|
||||
ClientDto dto = new(
|
||||
client.Id,
|
||||
client.WorkspaceId,
|
||||
client.Name,
|
||||
client.Status,
|
||||
client.PortraitUrl,
|
||||
client.PrimaryContactName,
|
||||
client.PrimaryContactEmail,
|
||||
client.PrimaryContactPortraitUrl);
|
||||
|
||||
await SendOkAsync(dto, ct);
|
||||
}
|
||||
}
|
||||
16
backend/Modules/Comments/Data/Comment.cs
Normal file
16
backend/Modules/Comments/Data/Comment.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace Socialize.Modules.Comments.Data;
|
||||
|
||||
public class Comment
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid ContentItemId { get; set; }
|
||||
public Guid? ParentCommentId { get; set; }
|
||||
public Guid AuthorUserId { get; set; }
|
||||
public required string AuthorDisplayName { get; set; }
|
||||
public required string AuthorEmail { get; set; }
|
||||
public required string Body { get; set; }
|
||||
public bool IsResolved { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? ResolvedAt { get; set; }
|
||||
}
|
||||
12
backend/Modules/Comments/DependencyInjection.cs
Normal file
12
backend/Modules/Comments/DependencyInjection.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Socialize.Modules.Comments.Data;
|
||||
|
||||
namespace Socialize.Modules.Comments;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddCommentsModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
120
backend/Modules/Comments/Handlers/CreateComment.cs
Normal file
120
backend/Modules/Comments/Handlers/CreateComment.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Notifications.Contracts;
|
||||
|
||||
namespace Socialize.Modules.Comments.Handlers;
|
||||
|
||||
public record CreateCommentRequest(
|
||||
Guid WorkspaceId,
|
||||
Guid ContentItemId,
|
||||
Guid? ParentCommentId,
|
||||
string Body);
|
||||
|
||||
public class CreateCommentRequestValidator
|
||||
: Validator<CreateCommentRequest>
|
||||
{
|
||||
public CreateCommentRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.WorkspaceId).NotEmpty();
|
||||
RuleFor(x => x.ContentItemId).NotEmpty();
|
||||
RuleFor(x => x.Body).NotEmpty().MaximumLength(4000);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateCommentHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: Endpoint<CreateCommentRequest, CommentDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/comments");
|
||||
Options(o => o.WithTags("Comments"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateCommentRequest request, CancellationToken ct)
|
||||
{
|
||||
ContentItem? contentItem = await dbContext.ContentItems
|
||||
.SingleOrDefaultAsync(
|
||||
candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId,
|
||||
ct);
|
||||
|
||||
if (contentItem is null)
|
||||
{
|
||||
AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.ParentCommentId.HasValue)
|
||||
{
|
||||
bool parentExists = await dbContext.Comments
|
||||
.AnyAsync(
|
||||
comment => comment.Id == request.ParentCommentId.Value && comment.ContentItemId == request.ContentItemId,
|
||||
ct);
|
||||
|
||||
if (!parentExists)
|
||||
{
|
||||
AddError(request => request.ParentCommentId, "The selected parent comment does not exist.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Comment comment = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = request.WorkspaceId,
|
||||
ContentItemId = request.ContentItemId,
|
||||
ParentCommentId = request.ParentCommentId,
|
||||
AuthorUserId = User.GetUserId(),
|
||||
AuthorDisplayName = User.GetAlias() ?? User.GetName(),
|
||||
AuthorEmail = User.GetEmail(),
|
||||
Body = request.Body.Trim(),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
dbContext.Comments.Add(comment);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
string? authorPortraitUrl = await dbContext.Users
|
||||
.Where(candidate => candidate.Id == comment.AuthorUserId)
|
||||
.Select(candidate => candidate.PortraitUrl)
|
||||
.SingleOrDefaultAsync(ct);
|
||||
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
comment.WorkspaceId,
|
||||
comment.ContentItemId,
|
||||
"comment.created",
|
||||
"Comment",
|
||||
comment.Id,
|
||||
$"{comment.AuthorDisplayName} commented on {contentItem.Title}.",
|
||||
null,
|
||||
null,
|
||||
$$"""{"parentCommentId":"{{comment.ParentCommentId}}"}"""),
|
||||
ct);
|
||||
|
||||
CommentDto dto = new(
|
||||
comment.Id,
|
||||
comment.WorkspaceId,
|
||||
comment.ContentItemId,
|
||||
comment.ParentCommentId,
|
||||
comment.AuthorUserId,
|
||||
comment.AuthorDisplayName,
|
||||
comment.AuthorEmail,
|
||||
authorPortraitUrl,
|
||||
comment.Body,
|
||||
comment.IsResolved,
|
||||
comment.CreatedAt,
|
||||
comment.ResolvedAt);
|
||||
|
||||
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
||||
}
|
||||
}
|
||||
80
backend/Modules/Comments/Handlers/GetComments.cs
Normal file
80
backend/Modules/Comments/Handlers/GetComments.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
|
||||
namespace Socialize.Modules.Comments.Handlers;
|
||||
|
||||
public record GetCommentsRequest(Guid ContentItemId);
|
||||
|
||||
public record CommentDto(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
Guid ContentItemId,
|
||||
Guid? ParentCommentId,
|
||||
Guid AuthorUserId,
|
||||
string AuthorDisplayName,
|
||||
string AuthorEmail,
|
||||
string? AuthorPortraitUrl,
|
||||
string Body,
|
||||
bool IsResolved,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ResolvedAt);
|
||||
|
||||
public class GetCommentsHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<GetCommentsRequest, IReadOnlyCollection<CommentDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/comments");
|
||||
Options(o => o.WithTags("Comments"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetCommentsRequest request, CancellationToken ct)
|
||||
{
|
||||
ContentItem? item = await dbContext.ContentItems
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId, ct);
|
||||
if (item is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
List<Comment> comments = await dbContext.Comments
|
||||
.Where(comment => comment.ContentItemId == request.ContentItemId)
|
||||
.OrderBy(comment => comment.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
List<Guid> authorIds = comments
|
||||
.Select(comment => comment.AuthorUserId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
Dictionary<Guid, string?> authorPortraits = await dbContext.Users
|
||||
.Where(user => authorIds.Contains(user.Id))
|
||||
.ToDictionaryAsync(user => user.Id, user => user.PortraitUrl, ct);
|
||||
|
||||
List<CommentDto> dtos = comments
|
||||
.Select(comment => new CommentDto(
|
||||
comment.Id,
|
||||
comment.WorkspaceId,
|
||||
comment.ContentItemId,
|
||||
comment.ParentCommentId,
|
||||
comment.AuthorUserId,
|
||||
comment.AuthorDisplayName,
|
||||
comment.AuthorEmail,
|
||||
authorPortraits.GetValueOrDefault(comment.AuthorUserId),
|
||||
comment.Body,
|
||||
comment.IsResolved,
|
||||
comment.CreatedAt,
|
||||
comment.ResolvedAt))
|
||||
.ToList();
|
||||
|
||||
await SendOkAsync(dtos, ct);
|
||||
}
|
||||
}
|
||||
84
backend/Modules/Comments/Handlers/ResolveComment.cs
Normal file
84
backend/Modules/Comments/Handlers/ResolveComment.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Notifications.Contracts;
|
||||
|
||||
namespace Socialize.Modules.Comments.Handlers;
|
||||
|
||||
public class ResolveCommentHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: EndpointWithoutRequest<CommentDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/comments/{id}/resolve");
|
||||
Options(o => o.WithTags("Comments"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
|
||||
Comment? comment = await dbContext.Comments.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
if (comment is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
ContentItem? contentItem = await dbContext.ContentItems
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == comment.ContentItemId, ct);
|
||||
if (contentItem is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
bool canResolve = accessScopeService.CanManageWorkspace(User, comment.WorkspaceId)
|
||||
|| accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId);
|
||||
|
||||
if (!canResolve)
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
comment.IsResolved = true;
|
||||
comment.ResolvedAt = comment.ResolvedAt ?? DateTimeOffset.UtcNow;
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
string? authorPortraitUrl = await dbContext.Users
|
||||
.Where(candidate => candidate.Id == comment.AuthorUserId)
|
||||
.Select(candidate => candidate.PortraitUrl)
|
||||
.SingleOrDefaultAsync(ct);
|
||||
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
comment.WorkspaceId,
|
||||
comment.ContentItemId,
|
||||
"comment.resolved",
|
||||
"Comment",
|
||||
comment.Id,
|
||||
$"{User.GetAlias() ?? User.GetName()} resolved a comment.",
|
||||
null,
|
||||
null,
|
||||
null),
|
||||
ct);
|
||||
|
||||
CommentDto dto = new(
|
||||
comment.Id,
|
||||
comment.WorkspaceId,
|
||||
comment.ContentItemId,
|
||||
comment.ParentCommentId,
|
||||
comment.AuthorUserId,
|
||||
comment.AuthorDisplayName,
|
||||
comment.AuthorEmail,
|
||||
authorPortraitUrl,
|
||||
comment.Body,
|
||||
comment.IsResolved,
|
||||
comment.CreatedAt,
|
||||
comment.ResolvedAt);
|
||||
|
||||
await SendOkAsync(dto, ct);
|
||||
}
|
||||
}
|
||||
18
backend/Modules/ContentItems/Data/ContentItem.cs
Normal file
18
backend/Modules/ContentItems/Data/ContentItem.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace Socialize.Modules.ContentItems.Data;
|
||||
|
||||
public class ContentItem
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid ClientId { get; set; }
|
||||
public Guid ProjectId { get; set; }
|
||||
public required string Title { get; set; }
|
||||
public required string PublicationMessage { get; set; }
|
||||
public required string PublicationTargets { get; set; }
|
||||
public string? Hashtags { get; set; }
|
||||
public required string Status { get; set; }
|
||||
public DateTimeOffset? DueDate { get; set; }
|
||||
public required string CurrentRevisionLabel { get; set; }
|
||||
public int CurrentRevisionNumber { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
16
backend/Modules/ContentItems/Data/ContentItemRevision.cs
Normal file
16
backend/Modules/ContentItems/Data/ContentItemRevision.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace Socialize.Modules.ContentItems.Data;
|
||||
|
||||
public class ContentItemRevision
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid ContentItemId { get; set; }
|
||||
public int RevisionNumber { get; set; }
|
||||
public required string RevisionLabel { get; set; }
|
||||
public required string Title { get; set; }
|
||||
public required string PublicationMessage { get; set; }
|
||||
public required string PublicationTargets { get; set; }
|
||||
public string? Hashtags { get; set; }
|
||||
public string? ChangeSummary { get; set; }
|
||||
public Guid? CreatedByUserId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
12
backend/Modules/ContentItems/DependencyInjection.cs
Normal file
12
backend/Modules/ContentItems/DependencyInjection.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Socialize.Modules.ContentItems.Data;
|
||||
|
||||
namespace Socialize.Modules.ContentItems;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddContentItemsModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
148
backend/Modules/ContentItems/Handlers/CreateContentItem.cs
Normal file
148
backend/Modules/ContentItems/Handlers/CreateContentItem.cs
Normal file
@@ -0,0 +1,148 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Notifications.Contracts;
|
||||
|
||||
namespace Socialize.Modules.ContentItems.Handlers;
|
||||
|
||||
public record CreateContentItemRequest(
|
||||
Guid WorkspaceId,
|
||||
Guid ClientId,
|
||||
Guid ProjectId,
|
||||
string Title,
|
||||
string PublicationMessage,
|
||||
string PublicationTargets,
|
||||
string? Hashtags,
|
||||
DateTimeOffset? DueDate);
|
||||
|
||||
public class CreateContentItemRequestValidator
|
||||
: Validator<CreateContentItemRequest>
|
||||
{
|
||||
public CreateContentItemRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.WorkspaceId).NotEmpty();
|
||||
RuleFor(x => x.ClientId).NotEmpty();
|
||||
RuleFor(x => x.ProjectId).NotEmpty();
|
||||
RuleFor(x => x.Title).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.PublicationMessage).NotEmpty().MaximumLength(4000);
|
||||
RuleFor(x => x.PublicationTargets).NotEmpty().MaximumLength(512);
|
||||
RuleFor(x => x.Hashtags).MaximumLength(1024);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateContentItemHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: Endpoint<CreateContentItemRequest, ContentItemDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/content-items");
|
||||
Options(o => o.WithTags("Content Items"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateContentItemRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!accessScopeService.CanContributeToProject(User, request.WorkspaceId, request.ClientId, request.ProjectId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
bool workspaceExists = await dbContext.Workspaces
|
||||
.AnyAsync(workspace => workspace.Id == request.WorkspaceId, ct);
|
||||
|
||||
if (!workspaceExists)
|
||||
{
|
||||
AddError(request => request.WorkspaceId, "The selected workspace does not exist.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
bool clientExists = await dbContext.Clients
|
||||
.AnyAsync(
|
||||
client => client.Id == request.ClientId && client.WorkspaceId == request.WorkspaceId,
|
||||
ct);
|
||||
|
||||
if (!clientExists)
|
||||
{
|
||||
AddError(request => request.ClientId, "The selected client does not belong to the active workspace.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
bool projectExists = await dbContext.Projects
|
||||
.AnyAsync(
|
||||
project => project.Id == request.ProjectId &&
|
||||
project.WorkspaceId == request.WorkspaceId &&
|
||||
project.ClientId == request.ClientId,
|
||||
ct);
|
||||
|
||||
if (!projectExists)
|
||||
{
|
||||
AddError(request => request.ProjectId, "The selected project does not belong to the selected client.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
ContentItem item = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = request.WorkspaceId,
|
||||
ClientId = request.ClientId,
|
||||
ProjectId = request.ProjectId,
|
||||
Title = request.Title.Trim(),
|
||||
PublicationMessage = request.PublicationMessage.Trim(),
|
||||
PublicationTargets = request.PublicationTargets.Trim(),
|
||||
Hashtags = string.IsNullOrWhiteSpace(request.Hashtags) ? null : request.Hashtags.Trim(),
|
||||
Status = "Draft",
|
||||
DueDate = request.DueDate,
|
||||
CurrentRevisionLabel = "v1",
|
||||
CurrentRevisionNumber = 1,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
dbContext.ContentItems.Add(item);
|
||||
dbContext.ContentItemRevisions.Add(new ContentItemRevision
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ContentItemId = item.Id,
|
||||
RevisionNumber = 1,
|
||||
RevisionLabel = "v1",
|
||||
Title = item.Title,
|
||||
PublicationMessage = item.PublicationMessage,
|
||||
PublicationTargets = item.PublicationTargets,
|
||||
Hashtags = item.Hashtags,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
item.WorkspaceId,
|
||||
item.Id,
|
||||
"content-item.created",
|
||||
"ContentItem",
|
||||
item.Id,
|
||||
$"Content item {item.Title} was created.",
|
||||
null,
|
||||
null,
|
||||
$$"""{"status":"{{item.Status}}","revisionLabel":"{{item.CurrentRevisionLabel}}"}"""),
|
||||
ct);
|
||||
|
||||
ContentItemDto dto = new(
|
||||
item.Id,
|
||||
item.WorkspaceId,
|
||||
item.ClientId,
|
||||
item.ProjectId,
|
||||
item.Title,
|
||||
item.PublicationMessage,
|
||||
item.PublicationTargets,
|
||||
item.Hashtags,
|
||||
item.Status,
|
||||
item.DueDate,
|
||||
item.CurrentRevisionLabel,
|
||||
item.CurrentRevisionNumber);
|
||||
|
||||
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Notifications.Contracts;
|
||||
|
||||
namespace Socialize.Modules.ContentItems.Handlers;
|
||||
|
||||
public record CreateContentItemRevisionRequest(
|
||||
string Title,
|
||||
string PublicationMessage,
|
||||
string PublicationTargets,
|
||||
string? Hashtags,
|
||||
string? ChangeSummary);
|
||||
|
||||
public class CreateContentItemRevisionRequestValidator
|
||||
: Validator<CreateContentItemRevisionRequest>
|
||||
{
|
||||
public CreateContentItemRevisionRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Title).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.PublicationMessage).NotEmpty().MaximumLength(4000);
|
||||
RuleFor(x => x.PublicationTargets).NotEmpty().MaximumLength(512);
|
||||
RuleFor(x => x.Hashtags).MaximumLength(1024);
|
||||
RuleFor(x => x.ChangeSummary).MaximumLength(1024);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateContentItemRevisionHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: Endpoint<CreateContentItemRevisionRequest, ContentItemRevisionDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/content-items/{id}/revisions");
|
||||
Options(o => o.WithTags("Content Items"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateContentItemRevisionRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
|
||||
ContentItem? item = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
if (item is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanContributeToProject(User, item.WorkspaceId, item.ClientId, item.ProjectId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
int revisionNumber = item.CurrentRevisionNumber + 1;
|
||||
string revisionLabel = $"v{revisionNumber}";
|
||||
|
||||
item.Title = request.Title.Trim();
|
||||
item.PublicationMessage = request.PublicationMessage.Trim();
|
||||
item.PublicationTargets = request.PublicationTargets.Trim();
|
||||
item.Hashtags = string.IsNullOrWhiteSpace(request.Hashtags) ? null : request.Hashtags.Trim();
|
||||
item.CurrentRevisionNumber = revisionNumber;
|
||||
item.CurrentRevisionLabel = revisionLabel;
|
||||
|
||||
if (item.Status == "Changes requested internally")
|
||||
{
|
||||
item.Status = "Internal changes in progress";
|
||||
}
|
||||
else if (item.Status == "Changes requested by client")
|
||||
{
|
||||
item.Status = "Client changes in progress";
|
||||
}
|
||||
|
||||
ContentItemRevision revision = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ContentItemId = item.Id,
|
||||
RevisionNumber = revisionNumber,
|
||||
RevisionLabel = revisionLabel,
|
||||
Title = item.Title,
|
||||
PublicationMessage = item.PublicationMessage,
|
||||
PublicationTargets = item.PublicationTargets,
|
||||
Hashtags = item.Hashtags,
|
||||
ChangeSummary = string.IsNullOrWhiteSpace(request.ChangeSummary) ? null : request.ChangeSummary.Trim(),
|
||||
CreatedByUserId = User.GetUserId(),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
dbContext.ContentItemRevisions.Add(revision);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
item.WorkspaceId,
|
||||
item.Id,
|
||||
"content-item.revision.created",
|
||||
"ContentItemRevision",
|
||||
revision.Id,
|
||||
$"Revision {revisionLabel} was created for {item.Title}.",
|
||||
User.GetUserId(),
|
||||
User.GetEmail(),
|
||||
$$"""{"revisionLabel":"{{revisionLabel}}","status":"{{item.Status}}"}"""),
|
||||
ct);
|
||||
|
||||
ContentItemRevisionDto dto = new(
|
||||
revision.Id,
|
||||
revision.ContentItemId,
|
||||
revision.RevisionNumber,
|
||||
revision.RevisionLabel,
|
||||
revision.Title,
|
||||
revision.PublicationMessage,
|
||||
revision.PublicationTargets,
|
||||
revision.Hashtags,
|
||||
revision.ChangeSummary,
|
||||
revision.CreatedByUserId,
|
||||
revision.CreatedAt);
|
||||
|
||||
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
||||
}
|
||||
}
|
||||
68
backend/Modules/ContentItems/Handlers/GetContentItem.cs
Normal file
68
backend/Modules/ContentItems/Handlers/GetContentItem.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.ContentItems.Data;
|
||||
|
||||
namespace Socialize.Modules.ContentItems.Handlers;
|
||||
|
||||
public record ContentItemDetailDto(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
Guid ClientId,
|
||||
Guid ProjectId,
|
||||
string Title,
|
||||
string PublicationMessage,
|
||||
string PublicationTargets,
|
||||
string? Hashtags,
|
||||
string Status,
|
||||
DateTimeOffset? DueDate,
|
||||
string CurrentRevisionLabel,
|
||||
int CurrentRevisionNumber,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public class GetContentItemHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: EndpointWithoutRequest<ContentItemDetailDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/content-items/{id}");
|
||||
Options(o => o.WithTags("Content Items"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
|
||||
ContentItemDetailDto? item = await dbContext.ContentItems
|
||||
.Where(candidate => candidate.Id == id)
|
||||
.Select(candidate => new ContentItemDetailDto(
|
||||
candidate.Id,
|
||||
candidate.WorkspaceId,
|
||||
candidate.ClientId,
|
||||
candidate.ProjectId,
|
||||
candidate.Title,
|
||||
candidate.PublicationMessage,
|
||||
candidate.PublicationTargets,
|
||||
candidate.Hashtags,
|
||||
candidate.Status,
|
||||
candidate.DueDate,
|
||||
candidate.CurrentRevisionLabel,
|
||||
candidate.CurrentRevisionNumber,
|
||||
candidate.CreatedAt))
|
||||
.SingleOrDefaultAsync(ct);
|
||||
|
||||
if (item is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(item, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
namespace Socialize.Modules.ContentItems.Handlers;
|
||||
|
||||
public record ContentItemRevisionDto(
|
||||
Guid Id,
|
||||
Guid ContentItemId,
|
||||
int RevisionNumber,
|
||||
string RevisionLabel,
|
||||
string Title,
|
||||
string PublicationMessage,
|
||||
string PublicationTargets,
|
||||
string? Hashtags,
|
||||
string? ChangeSummary,
|
||||
Guid? CreatedByUserId,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public class GetContentItemRevisionsHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: EndpointWithoutRequest<IReadOnlyCollection<ContentItemRevisionDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/content-items/{id}/revisions");
|
||||
Options(o => o.WithTags("Content Items"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
|
||||
ContentItem? item = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
if (item is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
List<ContentItemRevisionDto> revisions = await dbContext.ContentItemRevisions
|
||||
.Where(revision => revision.ContentItemId == id)
|
||||
.OrderByDescending(revision => revision.RevisionNumber)
|
||||
.Select(revision => new ContentItemRevisionDto(
|
||||
revision.Id,
|
||||
revision.ContentItemId,
|
||||
revision.RevisionNumber,
|
||||
revision.RevisionLabel,
|
||||
revision.Title,
|
||||
revision.PublicationMessage,
|
||||
revision.PublicationTargets,
|
||||
revision.Hashtags,
|
||||
revision.ChangeSummary,
|
||||
revision.CreatedByUserId,
|
||||
revision.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(revisions, ct);
|
||||
}
|
||||
}
|
||||
91
backend/Modules/ContentItems/Handlers/GetContentItems.cs
Normal file
91
backend/Modules/ContentItems/Handlers/GetContentItems.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.ContentItems.Data;
|
||||
|
||||
namespace Socialize.Modules.ContentItems.Handlers;
|
||||
|
||||
public record GetContentItemsRequest(Guid? WorkspaceId, Guid? ClientId, Guid? ProjectId);
|
||||
|
||||
public record ContentItemDto(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
Guid ClientId,
|
||||
Guid ProjectId,
|
||||
string Title,
|
||||
string PublicationMessage,
|
||||
string PublicationTargets,
|
||||
string? Hashtags,
|
||||
string Status,
|
||||
DateTimeOffset? DueDate,
|
||||
string CurrentRevisionLabel,
|
||||
int CurrentRevisionNumber);
|
||||
|
||||
public class GetContentItemsHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<GetContentItemsRequest, IReadOnlyCollection<ContentItemDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/content-items");
|
||||
Options(o => o.WithTags("Content Items"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetContentItemsRequest request, CancellationToken ct)
|
||||
{
|
||||
IQueryable<ContentItem> query = dbContext.ContentItems.AsQueryable();
|
||||
|
||||
if (!accessScopeService.IsManager(User))
|
||||
{
|
||||
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
|
||||
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
|
||||
IReadOnlyCollection<Guid> projectScopeIds = User.GetProjectScopeIds();
|
||||
|
||||
query = query.Where(item => workspaceScopeIds.Contains(item.WorkspaceId));
|
||||
|
||||
if (clientScopeIds.Count > 0)
|
||||
{
|
||||
query = query.Where(item => clientScopeIds.Contains(item.ClientId));
|
||||
}
|
||||
|
||||
if (projectScopeIds.Count > 0)
|
||||
{
|
||||
query = query.Where(item => projectScopeIds.Contains(item.ProjectId));
|
||||
}
|
||||
}
|
||||
|
||||
if (request.WorkspaceId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.WorkspaceId == request.WorkspaceId.Value);
|
||||
}
|
||||
|
||||
if (request.ProjectId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.ProjectId == request.ProjectId.Value);
|
||||
}
|
||||
|
||||
if (request.ClientId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.ClientId == request.ClientId.Value);
|
||||
}
|
||||
|
||||
List<ContentItemDto> items = await query
|
||||
.OrderBy(item => item.DueDate)
|
||||
.ThenBy(item => item.Title)
|
||||
.Select(item => new ContentItemDto(
|
||||
item.Id,
|
||||
item.WorkspaceId,
|
||||
item.ClientId,
|
||||
item.ProjectId,
|
||||
item.Title,
|
||||
item.PublicationMessage,
|
||||
item.PublicationTargets,
|
||||
item.Hashtags,
|
||||
item.Status,
|
||||
item.DueDate,
|
||||
item.CurrentRevisionLabel,
|
||||
item.CurrentRevisionNumber))
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(items, ct);
|
||||
}
|
||||
}
|
||||
105
backend/Modules/ContentItems/Handlers/UpdateContentItemStatus.cs
Normal file
105
backend/Modules/ContentItems/Handlers/UpdateContentItemStatus.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.ContentItems.Data;
|
||||
using Socialize.Modules.Notifications.Contracts;
|
||||
|
||||
namespace Socialize.Modules.ContentItems.Handlers;
|
||||
|
||||
public record UpdateContentItemStatusRequest(string Status);
|
||||
|
||||
public class UpdateContentItemStatusRequestValidator
|
||||
: Validator<UpdateContentItemStatusRequest>
|
||||
{
|
||||
public UpdateContentItemStatusRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Status).NotEmpty().MaximumLength(64);
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateContentItemStatusHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: Endpoint<UpdateContentItemStatusRequest, ContentItemDetailDto>
|
||||
{
|
||||
private static readonly HashSet<string> AllowedStatuses =
|
||||
[
|
||||
"Draft",
|
||||
"In internal review",
|
||||
"Changes requested internally",
|
||||
"Internal changes in progress",
|
||||
"Ready for client review",
|
||||
"In client review",
|
||||
"Changes requested by client",
|
||||
"Client changes in progress",
|
||||
"Approved",
|
||||
"Rejected",
|
||||
"Ready to publish",
|
||||
"Published",
|
||||
"Archived",
|
||||
];
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/content-items/{id}/status");
|
||||
Options(o => o.WithTags("Content Items"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(UpdateContentItemStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
|
||||
ContentItem? item = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
if (item is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanManageWorkspace(User, item.WorkspaceId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string normalizedStatus = request.Status.Trim();
|
||||
if (!AllowedStatuses.Contains(normalizedStatus))
|
||||
{
|
||||
AddError(request => request.Status, "The requested status is not valid.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
item.Status = normalizedStatus;
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
item.WorkspaceId,
|
||||
item.Id,
|
||||
"content-item.status.updated",
|
||||
"ContentItem",
|
||||
item.Id,
|
||||
$"Status changed to {item.Status} for {item.Title}.",
|
||||
User.GetUserId(),
|
||||
User.GetEmail(),
|
||||
$$"""{"status":"{{item.Status}}"}"""),
|
||||
ct);
|
||||
|
||||
ContentItemDetailDto dto = new(
|
||||
item.Id,
|
||||
item.WorkspaceId,
|
||||
item.ClientId,
|
||||
item.ProjectId,
|
||||
item.Title,
|
||||
item.PublicationMessage,
|
||||
item.PublicationTargets,
|
||||
item.Hashtags,
|
||||
item.Status,
|
||||
item.DueDate,
|
||||
item.CurrentRevisionLabel,
|
||||
item.CurrentRevisionNumber,
|
||||
item.CreatedAt);
|
||||
|
||||
await SendOkAsync(dto, ct);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user