Compare commits
133 Commits
df3e602015
...
feat/googl
| Author | SHA1 | Date | |
|---|---|---|---|
| 2eaba6983d | |||
| 1826045b99 | |||
| 85054d2113 | |||
| 39a68a71cd | |||
| d634648bed | |||
| 6ff54ab7e0 | |||
| ba9ce9b2d3 | |||
| 9b4ebef19a | |||
| 26f532c49b | |||
| 4ac8c039ed | |||
| 01a44abc9c | |||
| afcdd1ace1 | |||
| 831ffde411 | |||
| 5a798d6650 | |||
| ca68132546 | |||
| fc760736f8 | |||
| 87530bed84 | |||
| ebfa37f8cd | |||
| 581d286a1c | |||
| 030bf1b4ef | |||
| 986c7efea6 | |||
| 8bcff96821 | |||
| 1ca6ab7117 | |||
| e81c9f42c9 | |||
| c527011646 | |||
| 0b7edb1b7f | |||
| dcfdce1ec6 | |||
| 2eb54b9228 | |||
| 9c011f1a1e | |||
| b6eb348605 | |||
| 7a8a0a44bf | |||
| 6d92119c9c | |||
| db16e79d9f | |||
| 4aaa1a7f90 | |||
| 6ac05e1a10 | |||
| 9768a37252 | |||
| 98c76a7d88 | |||
| 49e2ca1774 | |||
| e9fb1c5ee0 | |||
| 57abe57bc7 | |||
| 9022fa7d93 | |||
| d1621ecb36 | |||
| 6e417312f9 | |||
| 918136aae2 | |||
| 0521d91240 | |||
| c18a223759 | |||
| 298c46de7c | |||
| 2d22fd6e04 | |||
| ef323c291f | |||
| 4eb0fbc22b | |||
| afe22949c5 | |||
| ebb87b286f | |||
| f1da3a44de | |||
| 419dbf0185 | |||
| 909ae6f092 | |||
| a97ff2dc38 | |||
| 7a862a202a | |||
| 1ae3188d34 | |||
| fb7811c469 | |||
| 0a6d730ca0 | |||
| d2d3bee975 | |||
| 78de068cd1 | |||
| 1965dc2c9e | |||
| f0d635ef21 | |||
| d59d667796 | |||
| 5c0e40db7e | |||
| dc9a980958 | |||
| c40653b2b7 | |||
| f240d32ce6 | |||
| 4775e35b3c | |||
| a7535d460d | |||
| db344eebac | |||
| 9699c4d55c | |||
| c183626a7a | |||
| 5db182dda9 | |||
| 6296a91c3d | |||
| 91b7f96fdb | |||
| 88c4c23ce1 | |||
| a96b3c897c | |||
| a437bfcfc3 | |||
| b7b282a71a | |||
| 6083797eb1 | |||
| ecbd3daa1b | |||
| b66c10b681 | |||
| c49f03ec06 | |||
| 23ae78f6e1 | |||
| 0d4188b64e | |||
| 78a7517de7 | |||
| 244be555f9 | |||
| 6e658b8215 | |||
| f6c351c31e | |||
| 5baacbceea | |||
| feef8cbafd | |||
| b7379cf823 | |||
| 664eb07201 | |||
| 58c1301054 | |||
| 552f4f1f21 | |||
| 8f4b95f311 | |||
| 4fba72e99c | |||
| 55d8acef4c | |||
| 7d3f495472 | |||
| 802668fb0b | |||
| cd6f402d9e | |||
| 9bdef978bd | |||
| 2d472892d6 | |||
| 884ca4b96d | |||
| df0409d7f6 | |||
| 5077f557f4 | |||
| 1722d65d22 | |||
| 14023e65d5 | |||
| 237b1a4242 | |||
| ace0279bd0 | |||
| 07458c1541 | |||
| a9bfdc460d | |||
| 258554f9d4 | |||
| 6731fb5d3a | |||
| 5aaddbca40 | |||
| 1263e28c00 | |||
| 4873f39192 | |||
| cb6948aa14 | |||
| f9960b4fc9 | |||
| 2e4c16621d | |||
| 60ce08ee86 | |||
| 0f3652c1a1 | |||
| 63738ad027 | |||
| 6177eec2bf | |||
| b51b8b4185 | |||
| d222e33667 | |||
| fcd80cd30f | |||
| 43bcf449fd | |||
| 20f8a14bfb | |||
| 121757546a | |||
| b6eb692c27 |
75
.gitea/workflows/deploy-socialize.yml
Normal file
75
.gitea/workflows/deploy-socialize.yml
Normal file
@@ -0,0 +1,75 @@
|
||||
name: deploy-socialize
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Docker CLI
|
||||
run: apt-get update && apt-get install -y docker.io
|
||||
- name: Login to Gitea container registry
|
||||
env:
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: printf '%s' "$REGISTRY_PASSWORD" | docker login git.mapachotes.com -u "$REGISTRY_USER" --password-stdin
|
||||
- name: Build images
|
||||
run: |
|
||||
docker build \
|
||||
-t git.mapachotes.com/jbourdon/socialize-api:${{ gitea.sha }} \
|
||||
-t git.mapachotes.com/jbourdon/socialize-api:latest \
|
||||
-f backend/src/Socialize.Api/Dockerfile .
|
||||
docker build \
|
||||
--build-arg VITE_API_URL=/ \
|
||||
-t git.mapachotes.com/jbourdon/socialize-web:${{ gitea.sha }} \
|
||||
-t git.mapachotes.com/jbourdon/socialize-web:latest \
|
||||
-f frontend/Dockerfile .
|
||||
- name: Push images
|
||||
run: |
|
||||
docker push git.mapachotes.com/jbourdon/socialize-api:${{ gitea.sha }}
|
||||
docker push git.mapachotes.com/jbourdon/socialize-api:latest
|
||||
docker push git.mapachotes.com/jbourdon/socialize-web:${{ gitea.sha }}
|
||||
docker push git.mapachotes.com/jbourdon/socialize-web:latest
|
||||
|
||||
deploy:
|
||||
needs: image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install SSH client
|
||||
run: apt-get update && apt-get install -y openssh-client
|
||||
- name: Deploy on sobina
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_SSH_PRIVATE_KEY_B64: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY_B64 }}
|
||||
SOCIALIZE_IMAGE_TAG: ${{ gitea.sha }}
|
||||
run: |
|
||||
: "${SOCIALIZE_IMAGE_TAG:?SOCIALIZE_IMAGE_TAG is required}"
|
||||
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s' "$DEPLOY_SSH_PRIVATE_KEY_B64" | base64 -d > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
|
||||
write_env_value() {
|
||||
key="$1"
|
||||
value="$2"
|
||||
escaped_value="$(printf '%s' "$value" | sed "s/'/'\\\\''/g")"
|
||||
printf "%s='%s'\n" "$key" "$escaped_value"
|
||||
}
|
||||
|
||||
deploy_env="$(mktemp)"
|
||||
{
|
||||
write_env_value SOCIALIZE_IMAGE_TAG "$SOCIALIZE_IMAGE_TAG"
|
||||
} > "$deploy_env"
|
||||
|
||||
scp -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new "$deploy_env" "$DEPLOY_USER@$DEPLOY_HOST:/srv/prod/socialize/.deploy.env"
|
||||
rm -f "$deploy_env"
|
||||
scp -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new deploy/compose.yml "$DEPLOY_USER@$DEPLOY_HOST:/srv/prod/socialize/compose.yml"
|
||||
|
||||
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
'test -r /etc/socialize/socialize.env && cd /srv/prod/socialize && ./deploy.sh'
|
||||
39
.github/workflows/backend-ci.yml
vendored
39
.github/workflows/backend-ci.yml
vendored
@@ -1,39 +0,0 @@
|
||||
name: Backend CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
AZURE_WEBAPP_NAME: hutopy-backend-api
|
||||
DOTNET_VERSION: '10.0.x'
|
||||
|
||||
jobs:
|
||||
build_and_deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: dev
|
||||
steps:
|
||||
|
||||
# Checkout the repository
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
# Setup .NET Core
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v1
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
|
||||
# Run dotnet publish
|
||||
- name: dotnet build and publish
|
||||
run: |
|
||||
cd backend
|
||||
dotnet publish --configuration Release --artifacts-path ./publish/ backend.sln
|
||||
|
||||
# Deploy to Azure WebApp
|
||||
- name: Deploy to Azure WebApp
|
||||
uses: azure/webapps-deploy@v2
|
||||
with:
|
||||
app-name: ${{ env.AZURE_WEBAPP_NAME }}
|
||||
publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
|
||||
package: './backend/publish/publish/Socialize.Api/release/'
|
||||
38
.github/workflows/frontend-ci.yml
vendored
38
.github/workflows/frontend-ci.yml
vendored
@@ -1,38 +0,0 @@
|
||||
name: Frontend CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
AZURE_SWA_NAME: hutopy-portal
|
||||
|
||||
jobs:
|
||||
build_and_deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
# Checkout the repository
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
# Npm install
|
||||
- name: npm install
|
||||
run: |
|
||||
cd frontend
|
||||
npm install
|
||||
|
||||
# Npm run build
|
||||
- name: npm run build
|
||||
run: |
|
||||
cd frontend
|
||||
npm run build
|
||||
|
||||
# Deploy to Azure SWA
|
||||
- name: Deploy to Azure SWA
|
||||
uses: azure/static-web-apps-deploy@v1
|
||||
with:
|
||||
action: "upload"
|
||||
app_location: 'frontend'
|
||||
output_location: 'dist'
|
||||
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_TOKEN }}
|
||||
24
.gitignore
vendored
24
.gitignore
vendored
@@ -19,13 +19,35 @@
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# .NET
|
||||
bin/
|
||||
obj/
|
||||
**/[Bb]in/
|
||||
**/[Oo]bj/
|
||||
**/[Bb]in[\\]*
|
||||
**/[Oo]bj[\\]*
|
||||
TestResults/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
|
||||
# Local environment files
|
||||
.env
|
||||
*.local
|
||||
.env.local
|
||||
.env.*.local
|
||||
App_Data/
|
||||
|
||||
# Local SSL certificates
|
||||
*.pem
|
||||
|
||||
# Ai
|
||||
# AI agent local state
|
||||
.agents
|
||||
.agents/
|
||||
.codex
|
||||
.codex/
|
||||
|
||||
# Generated local artifacts
|
||||
.artifacts/
|
||||
|
||||
239
AGENTS.md
239
AGENTS.md
@@ -1,104 +1,76 @@
|
||||
# AGENTS.md
|
||||
# AGENTS
|
||||
|
||||
## Purpose
|
||||
This document is a working guide for coding agents in this repository. It captures the current architecture, conventions, and safe execution workflow for making reliable changes.
|
||||
This repository is designed for human + AI agent collaboration.
|
||||
|
||||
## Documentation-First Workflow
|
||||
Agents must treat repository documentation as the source of truth. Conversation history is secondary and may be incomplete, stale, or contradictory.
|
||||
## Read Order
|
||||
|
||||
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
|
||||
Before meaningful code changes, read:
|
||||
|
||||
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.
|
||||
1. `README.md`
|
||||
2. `docs/AGENTIC_WORKFLOW.md`
|
||||
3. `docs/ARCHITECTURE.md`
|
||||
4. `docs/DEVELOPMENT_WORKFLOW.md`
|
||||
5. `docs/PRODUCT.md`
|
||||
6. `docs/CONVENTIONS.md`
|
||||
7. Relevant file in `docs/FEATURES/`
|
||||
8. Relevant file in `docs/TASKS/`
|
||||
|
||||
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.
|
||||
## Core Rules
|
||||
|
||||
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, 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.
|
||||
- Do not rewrite broad areas of the codebase without clear justification from the current task.
|
||||
- Preserve user changes in the worktree and treat uncommitted files as active collaboration unless told otherwise.
|
||||
- When creating commits, use the Conventional Commits format, for example `docs: update product planning`.
|
||||
- Do not invent architecture.
|
||||
- Work from docs, feature specs, and task files instead of long chat history.
|
||||
- Keep backend code under `backend/src/Socialize.Api`.
|
||||
- The solution file is `backend/Socialize.slnx`.
|
||||
- Backend feature code currently follows FastEndpoints module folders under `Modules/<Feature>`.
|
||||
- Frontend feature-owned code belongs under `frontend/src/features/<feature>`.
|
||||
- Frontend runtime config must flow through `frontend/src/config.js`.
|
||||
- If backend contracts change, run `./scripts/update-openapi.sh` when the backend is running.
|
||||
- Dev servers use HTTP and bind to `0.0.0.0` for LAN access.
|
||||
- Avoid broad refactors unless the task explicitly asks for one.
|
||||
|
||||
## Repository Layout
|
||||
- `backend/`: ASP.NET Core (`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/`: build/deploy pipelines for backend (Azure Web App) and frontend (Azure Static Web Apps).
|
||||
|
||||
- `backend/src/Socialize.Api/`: ASP.NET Core `net10.0` API using FastEndpoints, EF Core, PostgreSQL, ASP.NET Identity, and workflow modules.
|
||||
- `backend/tests/Socialize.Tests/`: backend test project scaffold.
|
||||
- `frontend/`: Vue 3 + Vite + Vuetify + Pinia SPA.
|
||||
- `docs/FEATURES/`: product and technical feature specs.
|
||||
- `docs/TASKS/`: implementation tickets for coding agents.
|
||||
- `docs/PROMPTS/`: reusable agent prompt templates.
|
||||
- `docs/DECISIONS/`: architecture and product decision records.
|
||||
- `shared/openapi/`: backend OpenAPI schema snapshots.
|
||||
- `scripts/`: root developer workflow commands.
|
||||
- `deploy/caddy/`: Caddy reverse proxy config for Docker Compose.
|
||||
|
||||
## Local Runbook
|
||||
### Backend
|
||||
- Prereqs: .NET 10 SDK, Docker, PostgreSQL container.
|
||||
- Start database:
|
||||
- `cd backend`
|
||||
- `./scripts/start-infrastructure.sh`
|
||||
- Run API:
|
||||
- `dotnet run --project Socialize.Api.csproj` (from `backend/`)
|
||||
- Local API URL:
|
||||
- `http://localhost:5000`
|
||||
- Swagger/OpenAPI UI in dev:
|
||||
- `/api`
|
||||
|
||||
### Frontend
|
||||
- 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`
|
||||
Start infrastructure:
|
||||
|
||||
## Backend Architecture
|
||||
### Composition Root
|
||||
- Entry point: `backend/Program.cs`.
|
||||
- Registers:
|
||||
- Web services/auth (`backend/DependencyInjection.cs`)
|
||||
- Infrastructure services (`backend/Infrastructure/DependencyInjection.cs`)
|
||||
- Modules: Identity, 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.
|
||||
```bash
|
||||
./scripts/start-infrastructure.sh
|
||||
```
|
||||
|
||||
### API Style
|
||||
- FastEndpoints-based handlers.
|
||||
- Pattern: request/response records + optional FluentValidation validator + handler class.
|
||||
- Tagging via `Options(o => o.WithTags("..."))`.
|
||||
- File upload handlers call `AllowFileUploads()`.
|
||||
Run backend:
|
||||
|
||||
### Data Boundaries
|
||||
- Separate DbContext per module:
|
||||
- Identity, Workspaces, Clients, Projects, ContentItems, Assets, Comments, Approvals, Notifications.
|
||||
- Migrations are module-scoped under each `Modules/*/Migrations` folder.
|
||||
```bash
|
||||
./scripts/dev-backend.sh
|
||||
```
|
||||
|
||||
### Auth/Security
|
||||
- JWT is generated manually in `Infrastructure/Security/GenerateJwtToken.cs`.
|
||||
- Refresh-token flow is implemented in Identity handlers (`/api/users/login`, `/api/users/refresh`).
|
||||
- User claim helpers live in `Infrastructure/Security/ClaimsPrincipalExtensions.cs`.
|
||||
- Role-gated frontend routes currently use `Administrator` and `Manager` checks for settings access.
|
||||
Run frontend:
|
||||
|
||||
```bash
|
||||
./scripts/dev-frontend.sh
|
||||
```
|
||||
|
||||
Update OpenAPI:
|
||||
|
||||
```bash
|
||||
./scripts/update-openapi.sh
|
||||
```
|
||||
|
||||
## Current Domain Modules
|
||||
|
||||
### Current Domain Modules
|
||||
- `Identity`: authentication, refresh tokens, email verification, password reset, social login.
|
||||
- `Organizations`: SaaS account ownership, billing/subscription boundary, organization membership, connectors, data mappings, and owned workspaces.
|
||||
- `Workspaces`: workspace membership, workspace settings, access scoping.
|
||||
- `Clients`: client records and primary contacts tied to workspaces.
|
||||
- `Projects`: project pipeline and client/project relationships.
|
||||
@@ -107,83 +79,46 @@ Do not use a long chat thread as the durable memory for the project. Durable dec
|
||||
- `Comments`: discussion threads on reviewable work.
|
||||
- `Approvals`: review decisions and workflow state transitions.
|
||||
- `Notifications`: activity feed and unread workflow notifications.
|
||||
- `Feedback`: product feedback reports, screenshots, comments, activity, and developer review workflows.
|
||||
|
||||
## 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.
|
||||
## Task Discipline
|
||||
|
||||
### Routing
|
||||
- Defined in `frontend/src/router/router.js`.
|
||||
- Route guards enforce:
|
||||
- `meta.requiresAuth`
|
||||
- `meta.notAuthenticated`
|
||||
- optional `meta.roles`
|
||||
- Primary authenticated app routes live under `/app/*`.
|
||||
Agents should work from task files in `docs/TASKS/`.
|
||||
|
||||
### 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.
|
||||
A good task:
|
||||
|
||||
### API Client
|
||||
- Axios client in `frontend/src/plugins/api.js`.
|
||||
- Injects bearer token, proactively refreshes near expiry, retries once on 401.
|
||||
- has a clear goal
|
||||
- names the relevant feature spec
|
||||
- has a small scope
|
||||
- lists likely files
|
||||
- lists validation commands
|
||||
|
||||
## High-Value Domains
|
||||
- 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`).
|
||||
If no task exists, create one before implementing a meaningful feature.
|
||||
|
||||
## 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.
|
||||
## Validation
|
||||
|
||||
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
|
||||
Backend:
|
||||
|
||||
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.
|
||||
```bash
|
||||
dotnet build backend/Socialize.slnx
|
||||
dotnet test backend/Socialize.slnx
|
||||
```
|
||||
|
||||
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.
|
||||
Frontend:
|
||||
|
||||
## 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. 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.
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Validation Checklist Before Finishing
|
||||
- Backend:
|
||||
- `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 when UI behavior changed
|
||||
- If migrations were changed:
|
||||
- ensure module context name/output directory remain consistent with `backend/scripts/add-migration.sh`.
|
||||
Contract changes:
|
||||
|
||||
## Notes / Known Sharp Edges
|
||||
- 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.
|
||||
```bash
|
||||
./scripts/update-openapi.sh
|
||||
```
|
||||
|
||||
## Sharp Edges
|
||||
|
||||
- Existing checked-in env and appsettings files may include legacy sensitive-looking values; do not propagate those values into new docs or templates.
|
||||
- The frontend is still JavaScript, not the TypeScript starter app generated by the bootstrap script. New OpenAPI scaffolding exists, but migrating app code to generated typed API calls should happen by task.
|
||||
- Feature-owned frontend route views and stores now live under `frontend/src/features/*`; keep future feature work there.
|
||||
|
||||
180
README.md
180
README.md
@@ -1,88 +1,160 @@
|
||||
# Socialize
|
||||
|
||||
Socialize is a workflow application for social media content review, revision, approval, and publication readiness.
|
||||
Socialize is an organization-owned, workspace-based workflow application for social media content review, revision, approval, and publication readiness.
|
||||
|
||||
It is not a public social network. The current product direction is a workspace-based review tool for internal teams, providers, and client approvers.
|
||||
It is not a public social network. The product is for internal teams, providers, and client approvers coordinating content work before publication.
|
||||
|
||||
## Repository Structure
|
||||
## Monorepo
|
||||
|
||||
- `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.
|
||||
|
||||
## Current Backend Modules
|
||||
|
||||
- `Identity`
|
||||
- `Workspaces`
|
||||
- `Clients`
|
||||
- `Projects`
|
||||
- `ContentItems`
|
||||
- `Assets`
|
||||
- `Comments`
|
||||
- `Approvals`
|
||||
- `Notifications`
|
||||
- Backend: .NET 10 Web API in `backend/src/Socialize.Api`
|
||||
- Backend tests: `backend/tests/Socialize.Tests`
|
||||
- Frontend: Vue 3 + Vite + Vuetify + Pinia in `frontend`
|
||||
- API contract: OpenAPI snapshot in `shared/openapi`
|
||||
- Deployment: Docker Compose + Caddy
|
||||
- Agentic workflow: specs, task files, and prompt templates under `docs`
|
||||
|
||||
## Local Development
|
||||
|
||||
### Backend
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- .NET 10 SDK
|
||||
- Docker
|
||||
|
||||
Start infrastructure:
|
||||
Terminal 1:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
./scripts/start-infrastructure.sh
|
||||
./scripts/dev-backend.sh
|
||||
```
|
||||
|
||||
Run the API:
|
||||
Terminal 2:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
dotnet run --project Socialize.Api.csproj
|
||||
./scripts/dev-frontend.sh
|
||||
```
|
||||
|
||||
Local backend URL:
|
||||
Frontend:
|
||||
|
||||
- `http://localhost:5000`
|
||||
- Swagger UI: `http://localhost:5000/api`
|
||||
```txt
|
||||
http://localhost:5173
|
||||
http://<this-machine-lan-ip>:5173
|
||||
```
|
||||
|
||||
### Frontend
|
||||
Backend:
|
||||
|
||||
Prerequisites:
|
||||
```txt
|
||||
http://localhost:5080
|
||||
http://<this-machine-lan-ip>:5080
|
||||
```
|
||||
|
||||
- Node.js / npm
|
||||
Swagger UI:
|
||||
|
||||
The frontend reads runtime values from:
|
||||
```txt
|
||||
http://localhost:5080/api
|
||||
```
|
||||
|
||||
- `frontend/.env.development`
|
||||
- `frontend/.env.production`
|
||||
- `frontend/src/config.js`
|
||||
## Update Frontend API Types
|
||||
|
||||
Run the frontend:
|
||||
The backend must be running first.
|
||||
|
||||
```bash
|
||||
./scripts/update-openapi.sh
|
||||
```
|
||||
|
||||
This writes:
|
||||
|
||||
```txt
|
||||
shared/openapi/openapi.json
|
||||
frontend/src/api/schema.d.ts
|
||||
```
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
```txt
|
||||
http://localhost:8080
|
||||
http://<this-machine-lan-ip>:8080
|
||||
```
|
||||
|
||||
For preprod deployment, configure the `POSTGRES_PASSWORD`, `RESEND_API_KEY`,
|
||||
`RESEND_FROM_EMAIL`, and `JWT_SIGNING_KEY` Gitea secrets.
|
||||
The deploy workflow writes the remote `.env` file and syncs `deploy/compose.yml`
|
||||
before running the server deploy script.
|
||||
Use the raw Resend API key value for `RESEND_API_KEY`, without a `Bearer ` prefix.
|
||||
|
||||
## Preprod Observability
|
||||
|
||||
The optional observability overlay runs a self-hosted Grafana stack for preproduction:
|
||||
|
||||
- Grafana `13.0.1`: dashboards
|
||||
- Prometheus `v3.11.3`: metrics and local alert rules
|
||||
- Loki `3.7.1`: Docker/container logs
|
||||
- Tempo `2.10.3`: traces
|
||||
- Grafana Alloy `v1.16.0`: OTLP receiver and Docker log collector
|
||||
|
||||
Start the app with observability:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/compose.yml -f deploy/observability/compose.observability.yml up -d
|
||||
```
|
||||
|
||||
Grafana is exposed at:
|
||||
|
||||
```txt
|
||||
http://127.0.0.1:3000
|
||||
```
|
||||
|
||||
Default credentials are `admin` / `admin` unless `GRAFANA_ADMIN_USER` and
|
||||
`GRAFANA_ADMIN_PASSWORD` are set. Set `GRAFANA_HTTP_BIND=0.0.0.0` only when the
|
||||
preprod network boundary is trusted or protected by a reverse proxy/VPN.
|
||||
|
||||
Set a non-default `GRAFANA_ADMIN_PASSWORD` before exposing Grafana outside the
|
||||
host. Prometheus alert rules are provisioned under
|
||||
`deploy/observability/prometheus/rules/`; notification delivery is intentionally
|
||||
left to the preprod operations environment.
|
||||
|
||||
Set `ALERTMANAGER_WEBHOOK_URL` to route alerts to a private notification endpoint.
|
||||
See `docs/OPERATIONS/observability-runbook.md` for bring-up, alert triage, and
|
||||
the optional protected Caddy configuration for Grafana.
|
||||
|
||||
## Solution
|
||||
|
||||
```bash
|
||||
dotnet build backend/Socialize.slnx
|
||||
dotnet test backend/Socialize.slnx
|
||||
```
|
||||
|
||||
## Frontend Build
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
npm run build
|
||||
```
|
||||
|
||||
Local frontend URL:
|
||||
## Database Diagram
|
||||
|
||||
- `http://localhost:5173`
|
||||
Start PostgreSQL, then generate a local schema diagram:
|
||||
|
||||
## Validation
|
||||
```bash
|
||||
./scripts/generate-db-diagram.sh
|
||||
```
|
||||
|
||||
- Backend: `cd backend && dotnet build Socialize.Api.csproj`
|
||||
- Frontend: `cd frontend && npm run build`
|
||||
The script writes an HTML viewer, SVG, PNG, and Graphviz source under:
|
||||
|
||||
## Docs
|
||||
```txt
|
||||
.artifacts/db-diagrams/
|
||||
```
|
||||
|
||||
- [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)
|
||||
Use `DATABASE_URL`, `PGPASSWORD`, or `~/.pgpass` to provide local database credentials.
|
||||
When using the repository infrastructure script, the diagram script can read from the
|
||||
running `socialize-postgres` container directly.
|
||||
|
||||
## Agentic Workflow
|
||||
|
||||
Start here:
|
||||
|
||||
```txt
|
||||
docs/AGENTIC_WORKFLOW.md
|
||||
```
|
||||
|
||||
Use feature specs, task files, and prompt templates instead of asking agents to work from vague chat history.
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# PROMPT TEMPLATES
|
||||
I need you to help me write a feature. First, we need to define it, so you will ask me questions one-by-one to make sure we have a shared understanding of the scope
|
||||
and expectating. - The feature we want is a way for your clients to report bugs/suggestions/requests from within our app. It should not be intrusive. It should allow
|
||||
them to take a screen capture, put annotation, describe their request and/or issue. Then, as a dev, i will want to collect and review them.
|
||||
|
||||
## Purpose
|
||||
This document standardizes how we interact with AI coding agents (Codex, Claude, etc).
|
||||
@@ -324,4 +327,4 @@ scripts/ai-task review docs/tasks/TASK-XXX.md
|
||||
|
||||
---
|
||||
|
||||
End of document.
|
||||
End of document.
|
||||
|
||||
82
backend/.github/workflows/build.yml
vendored
82
backend/.github/workflows/build.yml
vendored
@@ -1,82 +0,0 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- '.scripts/**'
|
||||
- .gitignore
|
||||
- CODE_OF_CONDUCT.md
|
||||
- LICENSE
|
||||
- README.md
|
||||
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-artifacts:
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
name: Checkout code
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nuget-
|
||||
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@v3
|
||||
|
||||
- name: Restore solution
|
||||
run: dotnet restore
|
||||
|
||||
- name: Build solution
|
||||
run: dotnet build --no-restore --configuration Release
|
||||
|
||||
- name: Test solution
|
||||
run: dotnet test --no-build --configuration Release --filter "FullyQualifiedName!~AcceptanceTests"
|
||||
|
||||
- name: Publish website
|
||||
if: ${{ inputs.build-artifacts == true }}
|
||||
run: |
|
||||
dotnet publish --configuration Release --runtime win-x86 --self-contained --output ./publish
|
||||
cd publish
|
||||
zip -r ./publish.zip .
|
||||
working-directory: ./src/Web/
|
||||
|
||||
- name: Upload website artifact (website)
|
||||
if: ${{ inputs.build-artifacts == true }}
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: website
|
||||
path: ./src/Web/publish/publish.zip
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create EF Core migrations bundle
|
||||
if: ${{ inputs.build-artifacts == true }}
|
||||
run: |
|
||||
dotnet new tool-manifest
|
||||
dotnet tool install dotnet-ef
|
||||
dotnet ef migrations bundle --configuration Release -p ./src/Infrastructure/ -s ./src/Web/ -o efbundle.exe
|
||||
zip -r ./efbundle.zip efbundle.exe
|
||||
env:
|
||||
SkipNSwag: True
|
||||
|
||||
- name: Upload EF Core migrations bundle artifact (efbundle)
|
||||
if: ${{ inputs.build-artifacts == true }}
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: efbundle
|
||||
path: ./efbundle.zip
|
||||
if-no-files-found: error
|
||||
42
backend/.github/workflows/cicd.yml
vendored
42
backend/.github/workflows/cicd.yml
vendored
@@ -1,42 +0,0 @@
|
||||
name: CICD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- .gitignore
|
||||
- CODE_OF_CONDUCT.md
|
||||
- LICENSE
|
||||
- README.md
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
uses: ./.github/workflows/build.yml
|
||||
with:
|
||||
build-artifacts: true
|
||||
|
||||
deploy-development:
|
||||
uses: ./.github/workflows/deploy.yml
|
||||
secrets: inherit
|
||||
needs: [ build ]
|
||||
with:
|
||||
environmentName: Development
|
||||
|
||||
deploy-staging:
|
||||
uses: ./.github/workflows/deploy.yml
|
||||
secrets: inherit
|
||||
needs: [ deploy-development ]
|
||||
with:
|
||||
environmentName: Staging
|
||||
|
||||
deploy-production:
|
||||
uses: ./.github/workflows/deploy.yml
|
||||
secrets: inherit
|
||||
needs: [ deploy-staging ]
|
||||
with:
|
||||
environmentName: Production
|
||||
107
backend/.github/workflows/deploy.yml
vendored
107
backend/.github/workflows/deploy.yml
vendored
@@ -1,107 +0,0 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
environmentName:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ inputs.environmentName }}
|
||||
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
name: Checkout code
|
||||
|
||||
- uses: azure/login@v1
|
||||
name: Login to Azure
|
||||
with:
|
||||
client-id: ${{ vars.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ vars.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- if: inputs.environmentName == 'Development'
|
||||
uses: azure/arm-deploy@v1
|
||||
name: Run preflight validation
|
||||
with:
|
||||
deploymentName: ${{ github.run_number }}
|
||||
resourceGroupName: ${{ vars.AZURE_RESOURCE_GROUP_NAME }}
|
||||
template: ./.azure/bicep/main.bicep
|
||||
parameters: >
|
||||
environmentName=${{ inputs.environmentName }}
|
||||
sqlAdministratorUsername=${{ vars.AZURE_SQL_ADMINISTRATOR_USERNAME }}
|
||||
sqlAdministratorPassword=${{ secrets.AZURE_SQL_ADMINISTRATOR_PASSWORD }}
|
||||
projectName=${{ vars.PROJECT_NAME }}
|
||||
deploymentMode: Validate
|
||||
|
||||
- if: inputs.environmentName != 'Development'
|
||||
uses: azure/arm-deploy@v1
|
||||
name: Run what-if
|
||||
with:
|
||||
failOnStdErr: false
|
||||
resourceGroupName: ${{ vars.AZURE_RESOURCE_GROUP_NAME }}
|
||||
template: ./.azure/bicep/main.bicep
|
||||
parameters: >
|
||||
environmentName=${{ inputs.environmentName }}
|
||||
sqlAdministratorUsername=${{ vars.AZURE_SQL_ADMINISTRATOR_USERNAME }}
|
||||
sqlAdministratorPassword=${{ secrets.AZURE_SQL_ADMINISTRATOR_PASSWORD }}
|
||||
projectName=${{ vars.PROJECT_NAME }}
|
||||
additionalArguments: --what-if
|
||||
|
||||
deploy:
|
||||
needs: [ validate ]
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ inputs.environmentName }}
|
||||
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
name: Checkout code
|
||||
|
||||
- uses: actions/download-artifact@v3
|
||||
name: Download artifacts
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@v3
|
||||
|
||||
- uses: azure/login@v1
|
||||
name: Login to Azure
|
||||
with:
|
||||
client-id: ${{ vars.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ vars.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- uses: azure/arm-deploy@v1
|
||||
id: deploy
|
||||
name: Deploy infrastructure
|
||||
with:
|
||||
failOnStdErr: false
|
||||
deploymentName: ${{ github.run_number }}
|
||||
resourceGroupName: ${{ vars.AZURE_RESOURCE_GROUP_NAME }}
|
||||
template: ./.azure/bicep/main.bicep
|
||||
parameters: >
|
||||
environmentName=${{ inputs.environmentName }}
|
||||
sqlAdministratorUsername=${{ vars.AZURE_SQL_ADMINISTRATOR_USERNAME }}
|
||||
sqlAdministratorPassword=${{ secrets.AZURE_SQL_ADMINISTRATOR_PASSWORD }}
|
||||
projectName=${{ vars.PROJECT_NAME }}
|
||||
|
||||
- name: Initialise database
|
||||
run: |
|
||||
unzip -o ./efbundle/efbundle.zip
|
||||
echo '{ "ConnectionStrings": { "DefaultConnection": "" } }' > appsettings.json
|
||||
./efbundle.exe --connection "Server=${{ steps.deploy.outputs.sqlServerFullyQualifiedDomainName }};Initial Catalog=${{ steps.deploy.outputs.sqlDatabaseName }};Persist Security Info=False;User ID=${{ vars.AZURE_SQL_ADMINISTRATOR_USERNAME }};Password=${{ secrets.AZURE_SQL_ADMINISTRATOR_PASSWORD }};MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" --verbose
|
||||
|
||||
- uses: azure/webapps-deploy@v2
|
||||
name: Deploy website
|
||||
with:
|
||||
app-name: ${{ steps.deploy.outputs.appServiceAppName }}
|
||||
package: website/publish.zip
|
||||
@@ -1,226 +0,0 @@
|
||||
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 +0,0 @@
|
||||
<wpf:ResourceDictionary xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:s="clr-namespace:System;assembly=mscorlib"
|
||||
xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xml:space="preserve">
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=hutopy/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
||||
@@ -1,14 +0,0 @@
|
||||
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,8 +0,0 @@
|
||||
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,44 +0,0 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Socialize.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
public static class ContentTypes
|
||||
{
|
||||
private const string ImagePng = "image/png";
|
||||
private const string ImageJpeg = "image/jpeg";
|
||||
private const string ImageJpg = "image/jpg";
|
||||
private const string TextHtml = "text/html";
|
||||
|
||||
private static readonly HashSet<string> AllowedContentTypes = [ImagePng, ImageJpeg, ImageJpg, TextHtml];
|
||||
|
||||
public static bool IsAllowed(
|
||||
string contentType,
|
||||
Stream fileStream)
|
||||
{
|
||||
return IsValidFileType(fileStream) && AllowedContentTypes.Contains(contentType);
|
||||
}
|
||||
|
||||
private static bool IsValidFileType(
|
||||
Stream fileStream)
|
||||
{
|
||||
byte[] buffer = new byte[512];
|
||||
_ = fileStream.Read(buffer, 0, buffer.Length);
|
||||
fileStream.Position = 0;
|
||||
|
||||
// PNG file signature: 89 50 4E 47 (in hex)
|
||||
if (buffer[0] == 0x89 && buffer[1] == 0x50 && buffer[2] == 0x4E && buffer[3] == 0x47)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// JPEG file signature: FF D8 FF (in hex)
|
||||
if (buffer[0] == 0xFF && buffer[1] == 0xD8 && buffer[2] == 0xFF)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for HTML content by looking for "<!DOCTYPE html>" or "<html>" tags
|
||||
string content = Encoding.UTF8.GetString(buffer);
|
||||
return content.Contains("<!DOCTYPE html>");
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Socialize.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
public static class SubDirectoryNames
|
||||
{
|
||||
public const string Profile = "profile";
|
||||
public const string Contents = "contents";
|
||||
public const string Albums = "albums";
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
using Azure;
|
||||
using Azure.Storage.Blobs;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
using Socialize.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
namespace Socialize.Infrastructure.BlobStorage.Services;
|
||||
|
||||
public class AzureBlobStorage : IBlobStorage
|
||||
{
|
||||
private const long MaxUploadSize = 10 * 1024 * 1024; // 10 MB in bytes
|
||||
|
||||
private readonly BlobServiceClient _blobServiceClient;
|
||||
private readonly ILogger<AzureBlobStorage> _logger;
|
||||
|
||||
public AzureBlobStorage(IConfiguration configuration, ILogger<AzureBlobStorage> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
string? connectionString = configuration.GetConnectionString("AzureBlob");
|
||||
_blobServiceClient = new BlobServiceClient(connectionString);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upload a file to microsoft azure blob storage.
|
||||
/// </summary>
|
||||
/// <param name="containerName">The name of the container where the file is stored.</param>
|
||||
/// <param name="blobName">The blob name (path within the container, include the file name).</param>
|
||||
/// <param name="stream"></param>
|
||||
/// <param name="contentType">The content type.</param>
|
||||
/// <param name="ct">The cancellation token</param>
|
||||
/// <returns></returns>
|
||||
public async Task<string> UploadFileAsync(
|
||||
string containerName,
|
||||
string blobName,
|
||||
Stream stream,
|
||||
string contentType,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Read the file stream into a memory stream to determine the length
|
||||
// WATCH FOR MEMORY USAGE USING THE MEMORY STREAM.
|
||||
stream.Position = 0;
|
||||
|
||||
// Check if the file size exceeds the maximum upload size
|
||||
if (stream.Length > MaxUploadSize)
|
||||
{
|
||||
_logger.LogError(
|
||||
$"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
|
||||
throw new InvalidOperationException(
|
||||
$"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
|
||||
}
|
||||
|
||||
// Validate content type
|
||||
if (!ContentTypes.IsAllowed(contentType, stream))
|
||||
{
|
||||
_logger.LogError(
|
||||
$"Blob storage: Unsupported file type {contentType}.");
|
||||
throw new InvalidOperationException("Unsupported file type.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Get a reference to a container
|
||||
BlobContainerClient? containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
|
||||
|
||||
// Create the container if it does not exist
|
||||
await containerClient.CreateIfNotExistsAsync(
|
||||
PublicAccessType.Blob,
|
||||
cancellationToken: ct);
|
||||
|
||||
// Get a reference to a blob
|
||||
BlobClient? blobClient = containerClient.GetBlobClient(blobName);
|
||||
|
||||
// Define the BlobHttpHeaders to include the content type
|
||||
BlobHttpHeaders blobHttpHeaders = new() { ContentType = contentType };
|
||||
|
||||
// Upload the file
|
||||
Response<BlobContentInfo>? response = await blobClient.UploadAsync(
|
||||
stream,
|
||||
new BlobUploadOptions { HttpHeaders = blobHttpHeaders },
|
||||
ct);
|
||||
|
||||
string fileUri = blobClient.Uri.ToString();
|
||||
|
||||
_logger.LogInformation(
|
||||
"""
|
||||
Blob storage: Status [ {ResponseStatus} ]
|
||||
Uploaded [ {BlobName} ] to the container [ {ContainerName} ]
|
||||
with contentType [ {ContentType} ]
|
||||
with a length of [ {StreamLength} bytes ]
|
||||
with the uri [ {FileUri} ]
|
||||
""",
|
||||
response.GetRawResponse().Status.ToString(),
|
||||
blobName,
|
||||
containerName,
|
||||
contentType,
|
||||
stream.Length,
|
||||
fileUri
|
||||
);
|
||||
|
||||
// Return the URI of the uploaded blob
|
||||
return fileUri;
|
||||
}
|
||||
catch (RequestFailedException ex)
|
||||
{
|
||||
_logger.LogError($"Blob storage: Azure Storage request failed: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError($"Blob storage: An error occurred: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download a file to microsoft's azure blob storage.
|
||||
/// </summary>
|
||||
/// <param name="blobName">The blob name (path within the container).</param>
|
||||
/// <param name="containerName">The name of the container where the file is stored. (users)</param>
|
||||
/// <param name="ct">The cancellation token for the request</param>
|
||||
/// <returns></returns>
|
||||
public async Task<MemoryStream> DownloadFileAsync(
|
||||
string containerName,
|
||||
string blobName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get a reference to a container
|
||||
BlobContainerClient? containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
|
||||
|
||||
// Get a reference to a blob
|
||||
BlobClient? blobClient = containerClient.GetBlobClient(blobName);
|
||||
|
||||
// Download the blob to a stream
|
||||
BlobDownloadInfo download = await blobClient.DownloadAsync(ct);
|
||||
|
||||
MemoryStream memoryStream = new();
|
||||
await download.Content.CopyToAsync(memoryStream, ct);
|
||||
memoryStream.Position = 0; // Ensure the stream is at the beginning
|
||||
|
||||
return memoryStream;
|
||||
}
|
||||
catch (RequestFailedException ex)
|
||||
{
|
||||
_logger.LogError($"Azure Storage request failed: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError($"An error occurred: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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 Socialize.Infrastructure;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddInfrastructureModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.Configure<WebsiteOptions>(
|
||||
builder.Configuration.GetRequiredSection(WebsiteOptions.SectionName));
|
||||
|
||||
builder.Services.AddTransient<IBlobStorage, AzureBlobStorage>();
|
||||
builder.Services.Configure<StripeOptions>(
|
||||
builder.Configuration.GetSection(StripeOptions.ConfigurationSection));
|
||||
|
||||
builder.Services.Configure<EmailerOptions>(
|
||||
builder.Configuration.GetSection(EmailerOptions.ConfigurationSection));
|
||||
builder.Services.AddTransient<IEmailSender, ResendEmailSender>();
|
||||
//builder.Services.AddTransient<IEmailSender, EmailSender>();
|
||||
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Socialize.Infrastructure.Development;
|
||||
|
||||
public record DevelopmentSeedOptions
|
||||
{
|
||||
public const string SectionName = "DevelopmentSeed";
|
||||
|
||||
public bool Enabled { get; init; } = true;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Socialize.Infrastructure.Emailer.Contracts;
|
||||
|
||||
public interface IEmailSender
|
||||
{
|
||||
Task SendEmailAsync(string email, string subject, string message);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using Socialize.Infrastructure.Emailer.Contracts;
|
||||
|
||||
namespace Socialize.Infrastructure.Emailer.Services;
|
||||
|
||||
public class LoggerEmailSender(ILogger<IEmailSender> logger)
|
||||
: IEmailSender
|
||||
{
|
||||
public async Task SendEmailAsync(string email, string subject, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.LogInformation("Sending email to {Email} with subject: {Subject}", email, subject);
|
||||
await Task.Delay(1000);
|
||||
logger.LogInformation("Email sent successfully to {Email}", email);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to send email to {Email}", email);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Socialize.Infrastructure.Emailer.Configuration;
|
||||
using Socialize.Infrastructure.Emailer.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Socialize.Infrastructure.Emailer.Services;
|
||||
|
||||
public class ResendEmailSender : IEmailSender
|
||||
{
|
||||
private static readonly Uri EndpointUri = new("https://api.resend.com/emails");
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly EmailerOptions _options;
|
||||
|
||||
public ResendEmailSender(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<EmailerOptions> options)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_options = options.Value;
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", _options.ApiKey);
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(
|
||||
new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
}
|
||||
|
||||
public async Task SendEmailAsync(string toEmail, string subject, string htmlMessage)
|
||||
{
|
||||
var payload = new { from = _options.FromEmail, to = toEmail, subject, html = htmlMessage };
|
||||
|
||||
string json = JsonSerializer.Serialize(payload);
|
||||
StringContent content = new(json, Encoding.UTF8, "application/json");
|
||||
|
||||
HttpResponseMessage response = await _httpClient.PostAsync(EndpointUri, content);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
string body = await response.Content.ReadAsStringAsync();
|
||||
throw new InvalidOperationException(
|
||||
$"Resend email failed: {response.StatusCode} - {body}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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,5 +0,0 @@
|
||||
namespace Socialize.Infrastructure.Security;
|
||||
|
||||
public class MissingClaimException(
|
||||
string claimName)
|
||||
: Exception($"Claim '{claimName}' is missing.");
|
||||
942
backend/Migrations/20260423061407_Initial.Designer.cs
generated
942
backend/Migrations/20260423061407_Initial.Designer.cs
generated
@@ -1,942 +0,0 @@
|
||||
// <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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,657 +0,0 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,939 +0,0 @@
|
||||
// <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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using Socialize.Modules.Approvals.Data;
|
||||
|
||||
namespace Socialize.Modules.Approvals;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddApprovalsModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using Socialize.Modules.Assets.Data;
|
||||
|
||||
namespace Socialize.Modules.Assets;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddAssetsModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using Socialize.Modules.ContentItems.Data;
|
||||
|
||||
namespace Socialize.Modules.ContentItems;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddContentItemsModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
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,10 +0,0 @@
|
||||
namespace Socialize.Modules.Identity.Contracts;
|
||||
|
||||
public static class KnownRoles
|
||||
{
|
||||
public const string Administrator = nameof(Administrator);
|
||||
public const string Manager = nameof(Manager);
|
||||
public const string Client = nameof(Client);
|
||||
public const string Provider = nameof(Provider);
|
||||
public const string WorkspaceMember = nameof(WorkspaceMember);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Socialize.Modules.Identity.Contracts;
|
||||
|
||||
public record UserReference(
|
||||
Guid Id,
|
||||
string Fullname,
|
||||
string? PortraitUrl);
|
||||
@@ -1,48 +0,0 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeEmailRequest(
|
||||
string? Email);
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangeEmailHandler(
|
||||
UserManager userManager)
|
||||
: Endpoint<ChangeEmailRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/users/email");
|
||||
Options(o => o.WithTags("Users"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ChangeEmailRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
user.Email = request.Email;
|
||||
|
||||
// TODO: check to see if identity resets the `email confirmed` flag - @jonathan
|
||||
IdentityResult result = await userManager.UpdateAsync(user);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await SendUnauthorizedAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangePhoneRequest(
|
||||
string? PhoneNumber);
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangePhoneHandler(
|
||||
UserManager userManager)
|
||||
: Endpoint<ChangePhoneRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/users/phone");
|
||||
Options(o => o.WithTags("Users"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ChangePhoneRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
user.PhoneNumber = request.PhoneNumber;
|
||||
// TODO: check to see if identity resets the `phone confirmed` flag - @jonathan
|
||||
|
||||
IdentityResult result = await userManager.UpdateAsync(user);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await SendUnauthorizedAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using System.Web;
|
||||
using Socialize.Infrastructure.Configuration;
|
||||
using Socialize.Infrastructure.Emailer.Contracts;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Socialize.Modules.Identity.Services;
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class EmailVerificationService(
|
||||
IOptionsSnapshot<WebsiteOptions> options,
|
||||
UserManager userManager,
|
||||
IEmailSender emailSender)
|
||||
{
|
||||
public async Task SendVerificationEmailAsync(
|
||||
User user)
|
||||
{
|
||||
// Generate email confirmation token
|
||||
string token = await userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
string encodedToken = HttpUtility.UrlEncode(token);
|
||||
string verificationLink = $"{options.Value.FrontendBaseUrl}/verify-email?userId={user.Id}&token={encodedToken}";
|
||||
|
||||
// Send verification email
|
||||
await emailSender.SendEmailAsync(
|
||||
user.Email!,
|
||||
"Verify your email address",
|
||||
$"""
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
|
||||
<h1 style="color: #2c3e50; margin-bottom: 20px;">Welcome to Socialize!</h1>
|
||||
|
||||
<p style="font-size: 16px; line-height: 1.5; margin-bottom: 25px;">
|
||||
Please verify your email address by clicking the button below:
|
||||
</p>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href='{verificationLink}'
|
||||
style="background-color: #3498db;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);">
|
||||
Verify Email Address
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 14px; color: #7f8c8d; margin-top: 30px;">
|
||||
If you did not request this, please ignore this email.
|
||||
</p>
|
||||
|
||||
<p style="font-size: 14px; color: #7f8c8d; margin-top: 20px;">
|
||||
If the button doesn't work, you can copy and paste this link into your browser:
|
||||
<br>
|
||||
<a href='{verificationLink}' style="color: #3498db; word-break: break-all;">{verificationLink}</a>
|
||||
</p>
|
||||
</div>
|
||||
""");
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
using Socialize.Modules.Notifications.Contracts;
|
||||
using Socialize.Modules.Notifications.Data;
|
||||
using Socialize.Modules.Notifications.Services;
|
||||
|
||||
namespace Socialize.Modules.Notifications;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddNotificationsModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddScoped<INotificationEventWriter, NotificationEventWriter>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using Socialize.Modules.Projects.Data;
|
||||
|
||||
namespace Socialize.Modules.Projects;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddProjectsModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Projects.Data;
|
||||
|
||||
namespace Socialize.Modules.Projects.Handlers;
|
||||
|
||||
public record GetProjectsRequest(Guid? WorkspaceId, Guid? ClientId);
|
||||
|
||||
public record ProjectDto(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
Guid ClientId,
|
||||
string Name,
|
||||
string? Description,
|
||||
string? Notes,
|
||||
string Status,
|
||||
DateTimeOffset StartDate,
|
||||
DateTimeOffset EndDate);
|
||||
|
||||
public class GetProjectsHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<GetProjectsRequest, IReadOnlyCollection<ProjectDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/projects");
|
||||
Options(o => o.WithTags("Projects"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetProjectsRequest request, CancellationToken ct)
|
||||
{
|
||||
IQueryable<Project> query = dbContext.Projects.AsQueryable();
|
||||
|
||||
if (accessScopeService.IsManager(User))
|
||||
{
|
||||
if (request.WorkspaceId.HasValue)
|
||||
{
|
||||
query = query.Where(project => project.WorkspaceId == request.WorkspaceId.Value);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
|
||||
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
|
||||
IReadOnlyCollection<Guid> projectScopeIds = User.GetProjectScopeIds();
|
||||
|
||||
query = query.Where(project => workspaceScopeIds.Contains(project.WorkspaceId));
|
||||
|
||||
if (clientScopeIds.Count > 0)
|
||||
{
|
||||
query = query.Where(project => clientScopeIds.Contains(project.ClientId));
|
||||
}
|
||||
|
||||
if (projectScopeIds.Count > 0)
|
||||
{
|
||||
query = query.Where(project => projectScopeIds.Contains(project.Id));
|
||||
}
|
||||
}
|
||||
|
||||
if (request.ClientId.HasValue)
|
||||
{
|
||||
query = query.Where(project => project.ClientId == request.ClientId.Value);
|
||||
}
|
||||
|
||||
if (request.WorkspaceId.HasValue)
|
||||
{
|
||||
query = query.Where(project => project.WorkspaceId == request.WorkspaceId.Value);
|
||||
}
|
||||
|
||||
List<ProjectDto> projects = await query
|
||||
.OrderBy(project => project.Name)
|
||||
.Select(project => new ProjectDto(
|
||||
project.Id,
|
||||
project.WorkspaceId,
|
||||
project.ClientId,
|
||||
project.Name,
|
||||
project.Description,
|
||||
project.Notes,
|
||||
project.Status,
|
||||
project.StartDate,
|
||||
project.EndDate))
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(projects, ct);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace Socialize.Modules.Workspaces.Data;
|
||||
|
||||
public class Workspace
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public required string Name { get; set; }
|
||||
public required string Slug { get; set; }
|
||||
public Guid OwnerUserId { get; set; }
|
||||
public required string TimeZone { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
using Socialize.Modules.Workspaces.Data;
|
||||
using Socialize.Infrastructure.Development;
|
||||
|
||||
namespace Socialize.Modules.Workspaces;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddWorkspaceModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.Configure<DevelopmentSeedOptions>(
|
||||
builder.Configuration.GetSection(DevelopmentSeedOptions.SectionName));
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Workspaces.Data;
|
||||
|
||||
namespace Socialize.Modules.Workspaces.Handlers;
|
||||
|
||||
public record CreateWorkspaceRequest(
|
||||
string Name,
|
||||
string Slug,
|
||||
string TimeZone);
|
||||
|
||||
public class CreateWorkspaceRequestValidator
|
||||
: Validator<CreateWorkspaceRequest>
|
||||
{
|
||||
public CreateWorkspaceRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.Slug)
|
||||
.NotEmpty()
|
||||
.MaximumLength(128)
|
||||
.Matches("^[a-z0-9]+(?:-[a-z0-9]+)*$");
|
||||
RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateWorkspaceHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<CreateWorkspaceRequest, WorkspaceDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/workspaces");
|
||||
Options(o => o.WithTags("Workspaces"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateWorkspaceRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!accessScopeService.IsManager(User))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string normalizedName = request.Name.Trim();
|
||||
string normalizedSlug = request.Slug.Trim().ToLowerInvariant();
|
||||
string normalizedTimeZone = request.TimeZone.Trim();
|
||||
|
||||
bool duplicateWorkspace = await dbContext.Workspaces
|
||||
.AnyAsync(workspace => workspace.Slug == normalizedSlug, ct);
|
||||
|
||||
if (duplicateWorkspace)
|
||||
{
|
||||
AddError(request => request.Slug, "A workspace with this slug already exists.");
|
||||
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Workspace workspace = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = normalizedName,
|
||||
Slug = normalizedSlug,
|
||||
OwnerUserId = User.GetUserId(),
|
||||
TimeZone = normalizedTimeZone,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
dbContext.Workspaces.Add(workspace);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
WorkspaceDto dto = new(
|
||||
workspace.Id,
|
||||
workspace.Name,
|
||||
workspace.Slug,
|
||||
workspace.TimeZone,
|
||||
workspace.CreatedAt);
|
||||
|
||||
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Workspaces.Data;
|
||||
|
||||
namespace Socialize.Modules.Workspaces.Handlers;
|
||||
|
||||
public record WorkspaceDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Slug,
|
||||
string TimeZone,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public class GetWorkspacesHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: EndpointWithoutRequest<IReadOnlyCollection<WorkspaceDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/workspaces");
|
||||
Options(o => o.WithTags("Workspaces"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
IQueryable<Workspace> query = dbContext.Workspaces.AsQueryable();
|
||||
|
||||
if (!accessScopeService.IsManager(User))
|
||||
{
|
||||
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
|
||||
query = query.Where(workspace => workspaceScopeIds.Contains(workspace.Id));
|
||||
}
|
||||
|
||||
List<WorkspaceDto> workspaces = await query
|
||||
.OrderBy(workspace => workspace.Name)
|
||||
.Select(workspace => new WorkspaceDto(
|
||||
workspace.Id,
|
||||
workspace.Name,
|
||||
workspace.Slug,
|
||||
workspace.TimeZone,
|
||||
workspace.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(workspaces, ct);
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
using Azure.Identity;
|
||||
using Socialize;
|
||||
using Socialize.Data;
|
||||
using Socialize.Infrastructure;
|
||||
using Socialize.Infrastructure.Development;
|
||||
using Socialize.Modules.Approvals;
|
||||
using Socialize.Modules.Assets;
|
||||
using Socialize.Modules.Clients;
|
||||
using Socialize.Modules.Comments;
|
||||
using Socialize.Modules.ContentItems;
|
||||
using Socialize.Modules.Identity;
|
||||
using Socialize.Modules.Notifications;
|
||||
using Socialize.Modules.Projects;
|
||||
using Socialize.Modules.Workspaces;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using NSwag;
|
||||
using NSwag.Generation.AspNetCore.Processors;
|
||||
using NSwag.Generation.Processors.Security;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
if (!builder.Environment.IsDevelopment())
|
||||
{
|
||||
string? vaultUri = Environment.GetEnvironmentVariable("VaultUri");
|
||||
|
||||
if (vaultUri is null)
|
||||
{
|
||||
throw new InvalidOperationException("Missing VaultUri configuration setting");
|
||||
}
|
||||
|
||||
builder.Configuration.AddAzureKeyVault(new Uri(vaultUri), new DefaultAzureCredential());
|
||||
}
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy(
|
||||
"AllowAll",
|
||||
policy =>
|
||||
{
|
||||
policy.AllowAnyOrigin()
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader();
|
||||
});
|
||||
});
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddWebServices();
|
||||
builder.Services.AddAuthorizationAndAuthentication(builder.Configuration);
|
||||
|
||||
builder.Services.AddOpenApiDocument((
|
||||
configure,
|
||||
_) =>
|
||||
{
|
||||
configure.Title = "Socialize API";
|
||||
|
||||
// Add JWT
|
||||
configure.AddSecurity(
|
||||
"JWT",
|
||||
[],
|
||||
new OpenApiSecurityScheme
|
||||
{
|
||||
Type = OpenApiSecuritySchemeType.ApiKey,
|
||||
Name = "Authorization",
|
||||
In = OpenApiSecurityApiKeyLocation.Header,
|
||||
Description = "Type into the textbox: Bearer {your JWT token}."
|
||||
});
|
||||
|
||||
configure.OperationProcessors.Add(new AspNetCoreOperationTagsProcessor());
|
||||
configure.OperationProcessors.Add(new AspNetCoreOperationSecurityScopeProcessor("JWT"));
|
||||
});
|
||||
|
||||
string postgresConnectionString = builder.Configuration.GetConnectionString("PostgresConnection")
|
||||
?? throw new InvalidOperationException(
|
||||
"Missing ConnectionStrings:PostgresConnection");
|
||||
|
||||
builder.Services.AddFastEndpoints();
|
||||
builder.Services.AddAppData(postgresConnectionString);
|
||||
builder.AddInfrastructureModule();
|
||||
builder.AddIdentityModule();
|
||||
builder.AddWorkspaceModule();
|
||||
builder.AddClientsModule();
|
||||
builder.AddProjectsModule();
|
||||
builder.AddContentItemsModule();
|
||||
builder.AddAssetsModule();
|
||||
builder.AddCommentsModule();
|
||||
builder.AddApprovalsModule();
|
||||
builder.AddNotificationsModule();
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
app.UseForwardedHeaders(
|
||||
new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedProto }
|
||||
);
|
||||
|
||||
app.UseCors("AllowAll");
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// Initialize and seed the db.
|
||||
await app.UseAppDataAsync();
|
||||
await app.UseIdentityModuleAsync();
|
||||
await app.UseDevelopmentSeedAsync();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
app.UseHealthChecks("/health");
|
||||
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseHttpsRedirection();
|
||||
}
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseOpenApi();
|
||||
app.UseSwaggerUi(options => options.Path = "/api");
|
||||
}
|
||||
|
||||
app.UseFastEndpoints();
|
||||
|
||||
app.Run();
|
||||
@@ -1,44 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>de6d03c4-8b1c-49e2-a8ca-c38cd4dc7d85</UserSecretsId>
|
||||
|
||||
<!-- Enable code analysis -->
|
||||
<EnableNETAnalyzers>true</EnableNETAnalyzers>
|
||||
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<WarningsAsErrors/>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.26.0"/>
|
||||
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.5.0"/>
|
||||
<PackageReference Include="Azure.Identity" Version="1.18.0"/>
|
||||
<PackageReference Include="FastEndpoints" Version="5.35.0"/>
|
||||
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Facebook" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.0"/>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0"/>
|
||||
<PackageReference Include="NSwag.AspNetCore" Version="14.3.0"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0"/>
|
||||
<PackageReference Include="Postmark" Version="5.2.0"/>
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11"/>
|
||||
<PackageReference Include="Stripe.net" Version="47.4.0"/>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.100" PrivateAssets="all"/>
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.12.0.118525" PrivateAssets="all"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
11
backend/Socialize.slnx
Normal file
11
backend/Socialize.slnx
Normal file
@@ -0,0 +1,11 @@
|
||||
<Solution>
|
||||
<Configurations>
|
||||
<Platform Name="Any CPU" />
|
||||
<Platform Name="x64" />
|
||||
<Platform Name="x86" />
|
||||
</Configurations>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/Socialize.Tests/Socialize.Tests.csproj" />
|
||||
</Folder>
|
||||
<Project Path="src/Socialize.Api/Socialize.Api.csproj" />
|
||||
</Solution>
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"PostgresConnection": "Server=hutopypostgress.postgres.database.azure.com,5432;Database=hutopy;User Id=hutopy;Password=General2024!;Ssl Mode=Require;"
|
||||
},
|
||||
"Stripe": {
|
||||
"SocializeRate": 0.05
|
||||
},
|
||||
"Website": {
|
||||
"FrontendBaseUrl": "https://hutopy.com"
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Socialize.Api", "Socialize.Api.csproj", "{D790B528-6968-4CCD-A25D-A108A82CBDAC}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{D790B528-6968-4CCD-A25D-A108A82CBDAC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D790B528-6968-4CCD-A25D-A108A82CBDAC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D790B528-6968-4CCD-A25D-A108A82CBDAC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D790B528-6968-4CCD-A25D-A108A82CBDAC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -13,5 +13,7 @@ fi
|
||||
dotnet ef migrations add \
|
||||
--context "Socialize.Modules.${MODULE_NAME}.Data.${MODULE_NAME}DbContext" \
|
||||
--configuration Debug \
|
||||
--project "src/Socialize.Api/Socialize.Api.csproj" \
|
||||
--startup-project "src/Socialize.Api/Socialize.Api.csproj" \
|
||||
--output-dir "Modules/${MODULE_NAME}/Migrations" \
|
||||
"$MIGRATION_NAME"
|
||||
|
||||
@@ -16,6 +16,8 @@ UPDATE_COMMAND=(
|
||||
dotnet ef database update
|
||||
--context "Socialize.Modules.${MODULE_NAME}.Data.${MODULE_NAME}DbContext"
|
||||
--configuration Debug
|
||||
--project "src/Socialize.Api/Socialize.Api.csproj"
|
||||
--startup-project "src/Socialize.Api/Socialize.Api.csproj"
|
||||
)
|
||||
|
||||
if [ -n "$TARGET_MIGRATION" ]; then
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Text;
|
||||
using Socialize.Data;
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Observability;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Facebook;
|
||||
using Microsoft.AspNetCore.Authentication.Google;
|
||||
@@ -10,7 +12,7 @@ using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Socialize;
|
||||
|
||||
public static class DependencyInjection
|
||||
internal static class ApplicationRegistration
|
||||
{
|
||||
public static IServiceCollection AddWebServices(this IServiceCollection services)
|
||||
{
|
||||
@@ -19,7 +21,10 @@ public static class DependencyInjection
|
||||
services.AddHttpContextAccessor();
|
||||
|
||||
services.AddHealthChecks()
|
||||
.AddDbContextCheck<AppDbContext>();
|
||||
.AddCheck("self", () => Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult.Healthy(), tags: ["live"])
|
||||
.AddDbContextCheck<AppDbContext>("postgres", tags: ["ready"])
|
||||
.AddCheck<LocalBlobStorageHealthCheck>("local_blob_storage", tags: ["ready"])
|
||||
.AddCheck<EmailerConfigurationHealthCheck>("emailer_configuration", tags: ["ready"]);
|
||||
|
||||
services.AddHttpClient();
|
||||
services.AddScoped<AccessScopeService>();
|
||||
@@ -49,7 +54,7 @@ public static class DependencyInjection
|
||||
{
|
||||
using IServiceScope scope = app.ApplicationServices.CreateScope();
|
||||
await using AppDbContext context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await context.Database.EnsureCreatedAsync(cancellationToken);
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -69,7 +74,6 @@ public static class DependencyInjection
|
||||
{
|
||||
authenticationBuilder.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, jwtBearerOptions =>
|
||||
{
|
||||
jwtBearerOptions.Authority = "https://hutopy.com";
|
||||
jwtBearerOptions.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
@@ -78,7 +82,7 @@ public static class DependencyInjection
|
||||
ValidAudience = authJwt["Audience"],
|
||||
ValidateLifetime = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authJwt["Key"] ??
|
||||
throw new ArgumentNullException("The Jwt Key is missing.")))
|
||||
throw new InvalidOperationException("Authentication:Jwt:Key is required.")))
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -89,9 +93,9 @@ public static class DependencyInjection
|
||||
authenticationBuilder.AddGoogle(GoogleDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.ClientId = authGoogle["ClientId"] ??
|
||||
throw new ArgumentNullException("The Google ClientId is missing.");
|
||||
throw new InvalidOperationException("Authentication:Google:ClientId is required.");
|
||||
options.ClientSecret = authGoogle["ClientSecret"] ??
|
||||
throw new ArgumentNullException("The Google ClientSecret is missing.");
|
||||
throw new InvalidOperationException("Authentication:Google:ClientSecret is required.");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -101,9 +105,9 @@ public static class DependencyInjection
|
||||
authenticationBuilder.AddFacebook(FacebookDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.ClientId = authFacebook["ClientId"] ??
|
||||
throw new ArgumentNullException("The Facebook ClientId is missing.");
|
||||
throw new InvalidOperationException("Authentication:Facebook:ClientId is required.");
|
||||
options.ClientSecret = authFacebook["ClientSecret"] ??
|
||||
throw new ArgumentNullException("The Facebook ClientSecret is missing.");
|
||||
throw new InvalidOperationException("Authentication:Facebook:ClientSecret is required.");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace Socialize.Common.Domain;
|
||||
namespace Socialize.Api.Common.Domain;
|
||||
|
||||
public abstract class Entity
|
||||
internal abstract class Entity
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid CreatedBy { get; init; }
|
||||
77
backend/src/Socialize.Api/Data/AppDbContext.cs
Normal file
77
backend/src/Socialize.Api/Data/AppDbContext.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Socialize.Api.Modules.Approvals.Data;
|
||||
using Socialize.Api.Modules.Assets.Data;
|
||||
using Socialize.Api.Modules.Channels.Data;
|
||||
using Socialize.Api.Modules.Clients.Data;
|
||||
using Socialize.Api.Modules.Comments.Data;
|
||||
using Socialize.Api.Modules.ContentItems.Data;
|
||||
using Socialize.Api.Modules.Feedback.Data;
|
||||
using Socialize.Api.Modules.Identity.Data;
|
||||
using Socialize.Api.Modules.Notifications.Data;
|
||||
using Socialize.Api.Modules.Campaigns.Data;
|
||||
using Socialize.Api.Modules.CalendarIntegrations.Data;
|
||||
using Socialize.Api.Modules.Organizations.Data;
|
||||
using Socialize.Api.Modules.ReleaseCommunications.Data;
|
||||
using Socialize.Api.Modules.Workspaces.Data;
|
||||
|
||||
namespace Socialize.Api.Data;
|
||||
|
||||
internal class AppDbContext(
|
||||
DbContextOptions<AppDbContext> options)
|
||||
: IdentityDbContext<User, Role, Guid>(options)
|
||||
{
|
||||
public DbSet<Organization> Organizations => Set<Organization>();
|
||||
public DbSet<OrganizationMembershipTier> OrganizationMembershipTiers => Set<OrganizationMembershipTier>();
|
||||
public DbSet<OrganizationMembershipTierTranslation> OrganizationMembershipTierTranslations =>
|
||||
Set<OrganizationMembershipTierTranslation>();
|
||||
public DbSet<OrganizationMembership> OrganizationMemberships => Set<OrganizationMembership>();
|
||||
public DbSet<Workspace> Workspaces => Set<Workspace>();
|
||||
public DbSet<WorkspaceInvite> WorkspaceInvites => Set<WorkspaceInvite>();
|
||||
public DbSet<Channel> Channels => Set<Channel>();
|
||||
public DbSet<Client> Clients => Set<Client>();
|
||||
public DbSet<Campaign> Campaigns => Set<Campaign>();
|
||||
public DbSet<ContentItem> ContentItems => Set<ContentItem>();
|
||||
public DbSet<ContentItemRevision> ContentItemRevisions => Set<ContentItemRevision>();
|
||||
public DbSet<ContentItemActivityEntry> ContentItemActivityEntries => Set<ContentItemActivityEntry>();
|
||||
public DbSet<Asset> Assets => Set<Asset>();
|
||||
public DbSet<AssetRevision> AssetRevisions => Set<AssetRevision>();
|
||||
public DbSet<Comment> Comments => Set<Comment>();
|
||||
public DbSet<ApprovalWorkflowInstance> ApprovalWorkflowInstances => Set<ApprovalWorkflowInstance>();
|
||||
public DbSet<ApprovalRequest> ApprovalRequests => Set<ApprovalRequest>();
|
||||
public DbSet<ApprovalDecision> ApprovalDecisions => Set<ApprovalDecision>();
|
||||
public DbSet<WorkspaceApprovalStepConfiguration> WorkspaceApprovalStepConfigurations => Set<WorkspaceApprovalStepConfiguration>();
|
||||
public DbSet<NotificationEvent> NotificationEvents => Set<NotificationEvent>();
|
||||
public DbSet<FeedbackReport> FeedbackReports => Set<FeedbackReport>();
|
||||
public DbSet<FeedbackTag> FeedbackTags => Set<FeedbackTag>();
|
||||
public DbSet<FeedbackScreenshot> FeedbackScreenshots => Set<FeedbackScreenshot>();
|
||||
public DbSet<FeedbackComment> FeedbackComments => Set<FeedbackComment>();
|
||||
public DbSet<FeedbackActivityEntry> FeedbackActivityEntries => Set<FeedbackActivityEntry>();
|
||||
public DbSet<CalendarSource> CalendarSources => Set<CalendarSource>();
|
||||
public DbSet<CalendarCatalogEntry> CalendarCatalogEntries => Set<CalendarCatalogEntry>();
|
||||
public DbSet<CalendarEvent> CalendarEvents => Set<CalendarEvent>();
|
||||
public DbSet<UserCalendarExportFeed> UserCalendarExportFeeds => Set<UserCalendarExportFeed>();
|
||||
public DbSet<ReleaseUpdate> ReleaseUpdates => Set<ReleaseUpdate>();
|
||||
public DbSet<ReleaseUpdateReadReceipt> ReleaseUpdateReadReceipts => Set<ReleaseUpdateReadReceipt>();
|
||||
public DbSet<ReleaseCommit> ReleaseCommits => Set<ReleaseCommit>();
|
||||
public DbSet<ReleaseUpdateEmailDigestReceipt> ReleaseUpdateEmailDigestReceipts => Set<ReleaseUpdateEmailDigestReceipt>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
|
||||
builder.ConfigureOrganizationsModule();
|
||||
builder.ConfigureWorkspacesModule();
|
||||
builder.ConfigureChannelsModule();
|
||||
builder.ConfigureClientsModule();
|
||||
builder.ConfigureCampaignsModule();
|
||||
builder.ConfigureContentItemsModule();
|
||||
builder.ConfigureAssetsModule();
|
||||
builder.ConfigureCommentsModule();
|
||||
builder.ConfigureApprovalsModule();
|
||||
builder.ConfigureNotificationsModule();
|
||||
builder.ConfigureFeedbackModule();
|
||||
builder.ConfigureCalendarIntegrationsModule();
|
||||
builder.ConfigureReleaseCommunicationsModule();
|
||||
}
|
||||
}
|
||||
25
backend/src/Socialize.Api/Dockerfile
Normal file
25
backend/src/Socialize.Api/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
COPY backend/Socialize.slnx backend/
|
||||
COPY backend/src/Socialize.Api/Socialize.Api.csproj backend/src/Socialize.Api/
|
||||
COPY backend/tests/Socialize.Tests/Socialize.Tests.csproj backend/tests/Socialize.Tests/
|
||||
|
||||
RUN dotnet restore backend/Socialize.slnx
|
||||
|
||||
COPY backend/ backend/
|
||||
|
||||
RUN dotnet publish backend/src/Socialize.Api/Socialize.Api.csproj \
|
||||
-c Release \
|
||||
-o /app/publish \
|
||||
--no-restore
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
ENV ASPNETCORE_URLS=http://0.0.0.0:8080
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["dotnet", "Socialize.Api.dll"]
|
||||
2
backend/src/Socialize.Api/GlobalUsings.cs
Normal file
2
backend/src/Socialize.Api/GlobalUsings.cs
Normal file
@@ -0,0 +1,2 @@
|
||||
global using FluentValidation;
|
||||
global using JetBrains.Annotations;
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Socialize.Api.Infrastructure.BlobStorage.Configuration;
|
||||
|
||||
internal sealed class LocalBlobStorageOptions
|
||||
{
|
||||
public const string SectionName = "LocalBlobStorage";
|
||||
|
||||
public string RootPath { get; set; } = "App_Data/blob-storage";
|
||||
|
||||
public string RequestPath { get; set; } = "/api/storage";
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace Socialize.Infrastructure.BlobStorage.Contracts;
|
||||
namespace Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
public static class CommonFileNames
|
||||
internal static class CommonFileNames
|
||||
{
|
||||
public const string ProfilePicture = "profilePicture";
|
||||
public const string LogoPicture = "logoPicture";
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
internal static class ContainerNames
|
||||
{
|
||||
public const string Users = "users";
|
||||
public const string Clients = "clients";
|
||||
public const string Organizations = "organizations";
|
||||
public const string Workspaces = "workspaces";
|
||||
public const string Creators = "creators";
|
||||
public const string Feedback = "feedback";
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
internal static class ContentTypes
|
||||
{
|
||||
private const string ImagePng = "image/png";
|
||||
private const string ImageJpeg = "image/jpeg";
|
||||
private const string ImageJpg = "image/jpg";
|
||||
private const string ImageGif = "image/gif";
|
||||
private const string ImageWebp = "image/webp";
|
||||
private const string VideoMp4 = "video/mp4";
|
||||
private const string VideoWebm = "video/webm";
|
||||
private const string VideoQuickTime = "video/quicktime";
|
||||
private const string TextHtml = "text/html";
|
||||
|
||||
private static readonly HashSet<string> AllowedContentTypes =
|
||||
[
|
||||
ImagePng,
|
||||
ImageJpeg,
|
||||
ImageJpg,
|
||||
ImageGif,
|
||||
ImageWebp,
|
||||
VideoMp4,
|
||||
VideoWebm,
|
||||
VideoQuickTime,
|
||||
TextHtml,
|
||||
];
|
||||
|
||||
public static bool IsAllowed(
|
||||
string contentType,
|
||||
Stream fileStream)
|
||||
{
|
||||
return IsValidFileType(fileStream) && AllowedContentTypes.Contains(contentType);
|
||||
}
|
||||
|
||||
private static bool IsValidFileType(
|
||||
Stream fileStream)
|
||||
{
|
||||
byte[] buffer = new byte[512];
|
||||
_ = fileStream.Read(buffer, 0, buffer.Length);
|
||||
fileStream.Position = 0;
|
||||
|
||||
// PNG file signature: 89 50 4E 47 (in hex)
|
||||
if (buffer[0] == 0x89 && buffer[1] == 0x50 && buffer[2] == 0x4E && buffer[3] == 0x47)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// JPEG file signature: FF D8 FF (in hex)
|
||||
if (buffer[0] == 0xFF && buffer[1] == 0xD8 && buffer[2] == 0xFF)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// GIF file signatures: GIF87a and GIF89a
|
||||
if (buffer[0] == 0x47 && buffer[1] == 0x49 && buffer[2] == 0x46 &&
|
||||
buffer[3] == 0x38 && (buffer[4] == 0x37 || buffer[4] == 0x39) && buffer[5] == 0x61)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// WebP files are RIFF containers with a WEBP marker.
|
||||
if (buffer[0] == 0x52 && buffer[1] == 0x49 && buffer[2] == 0x46 && buffer[3] == 0x46 &&
|
||||
buffer[8] == 0x57 && buffer[9] == 0x45 && buffer[10] == 0x42 && buffer[11] == 0x50)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// MP4/MOV containers expose an ftyp box near the beginning.
|
||||
if (buffer[4] == 0x66 && buffer[5] == 0x74 && buffer[6] == 0x79 && buffer[7] == 0x70)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// WebM files use the EBML header.
|
||||
if (buffer[0] == 0x1A && buffer[1] == 0x45 && buffer[2] == 0xDF && buffer[3] == 0xA3)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for HTML content by looking for "<!DOCTYPE html>" or "<html>" tags
|
||||
string content = Encoding.UTF8.GetString(buffer);
|
||||
return content.Contains("<!DOCTYPE html>", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace Socialize.Infrastructure.BlobStorage.Contracts;
|
||||
namespace Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
public interface IBlobStorage
|
||||
internal interface IBlobStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Upload a file to blob storage.
|
||||
@@ -29,4 +29,9 @@ public interface IBlobStorage
|
||||
string containerName,
|
||||
string blobName,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task DeleteFileAsync(
|
||||
string containerName,
|
||||
string blobName,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
internal static class SubDirectoryNames
|
||||
{
|
||||
public const string Profile = "profile";
|
||||
public const string Contents = "contents";
|
||||
public const string Albums = "albums";
|
||||
public const string FeedbackScreenshots = "screenshots";
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||
using Socialize.Api.Infrastructure.Observability;
|
||||
|
||||
namespace Socialize.Api.Infrastructure.BlobStorage.Services;
|
||||
|
||||
internal sealed class LocalBlobStorage(
|
||||
IWebHostEnvironment environment,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IOptions<LocalBlobStorageOptions> options,
|
||||
ILogger<LocalBlobStorage> logger,
|
||||
SocializeMetrics metrics)
|
||||
: IBlobStorage
|
||||
{
|
||||
private const long MaxUploadSize = 10 * 1024 * 1024;
|
||||
private const string ContentTypeMetadataSuffix = ".content-type";
|
||||
|
||||
private static readonly char[] PathSeparators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar];
|
||||
|
||||
private static readonly Action<ILogger, string, string, string, string, Exception?> LogUploadedFile =
|
||||
LoggerMessage.Define<string, string, string, string>(
|
||||
LogLevel.Information,
|
||||
new EventId(1, nameof(UploadFileAsync)),
|
||||
"Blob storage: Uploaded [{BlobName}] to local container [{ContainerName}] with contentType [{ContentType}] and uri [{FileUri}]");
|
||||
|
||||
private readonly LocalBlobStorageOptions _options = options.Value;
|
||||
|
||||
public async Task<string> UploadFileAsync(
|
||||
string containerName,
|
||||
string blobName,
|
||||
Stream stream,
|
||||
string contentType,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
stream.Position = 0;
|
||||
|
||||
if (stream.Length > MaxUploadSize)
|
||||
{
|
||||
logger.LogError("Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.", MaxUploadSize);
|
||||
throw new InvalidOperationException($"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
|
||||
}
|
||||
|
||||
if (!ContentTypes.IsAllowed(contentType, stream))
|
||||
{
|
||||
logger.LogError("Blob storage: Unsupported file type {ContentType}.", contentType);
|
||||
throw new InvalidOperationException("Unsupported file type.");
|
||||
}
|
||||
|
||||
string relativePath = GetSafeRelativePath(containerName, blobName);
|
||||
string filePath = Path.Combine(GetRootPath(), relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(filePath) ?? GetRootPath());
|
||||
|
||||
await using FileStream fileStream = File.Create(filePath);
|
||||
await stream.CopyToAsync(fileStream, ct);
|
||||
await File.WriteAllTextAsync(GetContentTypeMetadataPath(filePath), contentType, ct);
|
||||
|
||||
string fileUri = BuildPublicUrl(relativePath);
|
||||
LogUploadedFile(logger, blobName, containerName, contentType, fileUri, null);
|
||||
metrics.RecordBlobStorageOperation("upload", true);
|
||||
|
||||
return fileUri;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
metrics.RecordBlobStorageOperation("upload", false);
|
||||
throw;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
metrics.RecordBlobStorageOperation("upload", false);
|
||||
throw;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
metrics.RecordBlobStorageOperation("upload", false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<MemoryStream> DownloadFileAsync(
|
||||
string containerName,
|
||||
string blobName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
string filePath = Path.Combine(GetRootPath(), GetSafeRelativePath(containerName, blobName));
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException("Blob storage: Local file was not found.", blobName);
|
||||
}
|
||||
|
||||
MemoryStream memoryStream = new();
|
||||
await using FileStream fileStream = File.OpenRead(filePath);
|
||||
await fileStream.CopyToAsync(memoryStream, ct);
|
||||
memoryStream.Position = 0;
|
||||
metrics.RecordBlobStorageOperation("download", true);
|
||||
|
||||
return memoryStream;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
metrics.RecordBlobStorageOperation("download", false);
|
||||
throw;
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
metrics.RecordBlobStorageOperation("download", false);
|
||||
throw;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
metrics.RecordBlobStorageOperation("download", false);
|
||||
throw;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
metrics.RecordBlobStorageOperation("download", false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task DeleteFileAsync(
|
||||
string containerName,
|
||||
string blobName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_ = ct;
|
||||
|
||||
string filePath = Path.Combine(GetRootPath(), GetSafeRelativePath(containerName, blobName));
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
|
||||
string metadataPath = GetContentTypeMetadataPath(filePath);
|
||||
if (File.Exists(metadataPath))
|
||||
{
|
||||
File.Delete(metadataPath);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
internal string GetRootPath()
|
||||
{
|
||||
if (Path.IsPathRooted(_options.RootPath))
|
||||
{
|
||||
return Path.GetFullPath(_options.RootPath);
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(environment.ContentRootPath, _options.RootPath));
|
||||
}
|
||||
|
||||
internal static string? ReadContentType(string filePath)
|
||||
{
|
||||
string metadataPath = GetContentTypeMetadataPath(filePath);
|
||||
return File.Exists(metadataPath)
|
||||
? File.ReadAllText(metadataPath)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string GetContentTypeMetadataPath(string filePath)
|
||||
{
|
||||
return $"{filePath}{ContentTypeMetadataSuffix}";
|
||||
}
|
||||
|
||||
private static string GetSafeRelativePath(string containerName, string blobName)
|
||||
{
|
||||
if (Path.IsPathRooted(containerName) || Path.IsPathRooted(blobName))
|
||||
{
|
||||
throw new InvalidOperationException("Blob storage: Blob paths must be relative.");
|
||||
}
|
||||
|
||||
string[] pathParts = [containerName, .. blobName.Split(PathSeparators)];
|
||||
if (pathParts.Any(part => part is "" or "." or ".."))
|
||||
{
|
||||
throw new InvalidOperationException("Blob storage: Blob paths must not contain relative path segments.");
|
||||
}
|
||||
|
||||
return Path.Combine(pathParts);
|
||||
}
|
||||
|
||||
private string BuildPublicUrl(string relativePath)
|
||||
{
|
||||
HttpRequest? request = httpContextAccessor.HttpContext?.Request;
|
||||
string requestPath = NormalizeRequestPath(_options.RequestPath);
|
||||
string urlPath = $"{requestPath}/{relativePath.Replace(Path.DirectorySeparatorChar, '/')}";
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return urlPath;
|
||||
}
|
||||
|
||||
return $"{request.Scheme}://{request.Host}{request.PathBase}{urlPath}";
|
||||
}
|
||||
|
||||
internal static string NormalizeRequestPath(string requestPath)
|
||||
{
|
||||
string normalized = string.IsNullOrWhiteSpace(requestPath)
|
||||
? "/api/storage"
|
||||
: requestPath.Trim();
|
||||
|
||||
return normalized.StartsWith('/')
|
||||
? normalized.TrimEnd('/')
|
||||
: $"/{normalized.TrimEnd('/')}";
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace Socialize.Infrastructure.Configuration;
|
||||
namespace Socialize.Api.Infrastructure.Configuration;
|
||||
|
||||
public class WebsiteOptions
|
||||
internal class WebsiteOptions
|
||||
{
|
||||
public const string SectionName = "Website";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace Socialize.Infrastructure.Emailer.Configuration;
|
||||
namespace Socialize.Api.Infrastructure.Emailer.Configuration;
|
||||
|
||||
public class EmailerOptions
|
||||
internal class EmailerOptions
|
||||
{
|
||||
public const string ConfigurationSection = "Emailer";
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Socialize.Api.Infrastructure.Emailer.Contracts;
|
||||
|
||||
internal interface IEmailSender
|
||||
{
|
||||
Task SendEmailAsync(string email, string subject, string message);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Socialize.Api.Infrastructure.Emailer.Contracts;
|
||||
using Socialize.Api.Infrastructure.Observability;
|
||||
|
||||
namespace Socialize.Api.Infrastructure.Emailer.Services;
|
||||
|
||||
internal class LoggerEmailSender(
|
||||
ILogger<IEmailSender> logger,
|
||||
SocializeMetrics metrics)
|
||||
: IEmailSender
|
||||
{
|
||||
private static readonly Action<ILogger, string, string, string, string, Exception?> LogDevelopmentEmail =
|
||||
LoggerMessage.Define<string, string, string, string>(
|
||||
LogLevel.Information,
|
||||
new EventId(1, nameof(SendEmailAsync)),
|
||||
"Development email to {Email} with subject {Subject}:{NewLine}{Message}");
|
||||
|
||||
public Task SendEmailAsync(string email, string subject, string message)
|
||||
{
|
||||
LogDevelopmentEmail(logger, email, subject, Environment.NewLine, message, null);
|
||||
metrics.RecordEmailDelivery("logger", true);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
using Socialize.Infrastructure.Emailer.Configuration;
|
||||
using Socialize.Infrastructure.Emailer.Contracts;
|
||||
using Socialize.Api.Infrastructure.Emailer.Configuration;
|
||||
using Socialize.Api.Infrastructure.Emailer.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PostmarkDotNet;
|
||||
|
||||
namespace Socialize.Infrastructure.Emailer.Services;
|
||||
namespace Socialize.Api.Infrastructure.Emailer.Services;
|
||||
|
||||
public class PostmarkEmailSender : IEmailSender
|
||||
internal class PostmarkEmailSender : IEmailSender
|
||||
{
|
||||
private readonly PostmarkClient _client;
|
||||
private readonly EmailerOptions _options;
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Socialize.Api.Infrastructure.Emailer.Configuration;
|
||||
using Socialize.Api.Infrastructure.Emailer.Contracts;
|
||||
using Socialize.Api.Infrastructure.Observability;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Socialize.Api.Infrastructure.Emailer.Services;
|
||||
|
||||
internal class ResendEmailSender : IEmailSender
|
||||
{
|
||||
private static readonly Uri EndpointUri = new("https://api.resend.com/emails");
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly SocializeMetrics _metrics;
|
||||
private readonly EmailerOptions _options;
|
||||
|
||||
public ResendEmailSender(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<EmailerOptions> options,
|
||||
SocializeMetrics metrics)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_metrics = metrics;
|
||||
_options = options.Value;
|
||||
|
||||
string apiKey = NormalizeApiKey(_options.ApiKey);
|
||||
string fromEmail = _options.FromEmail?.Trim() ?? string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
throw new InvalidOperationException("Emailer:ApiKey is required when using Resend email delivery.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(fromEmail))
|
||||
{
|
||||
throw new InvalidOperationException("Emailer:FromEmail is required when using Resend email delivery.");
|
||||
}
|
||||
|
||||
_options.ApiKey = apiKey;
|
||||
_options.FromEmail = fromEmail;
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", apiKey);
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(
|
||||
new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
}
|
||||
|
||||
public async Task SendEmailAsync(string email, string subject, string message)
|
||||
{
|
||||
var payload = new { from = _options.FromEmail, to = email, subject, html = message };
|
||||
|
||||
string json = JsonSerializer.Serialize(payload);
|
||||
using StringContent content = new(json, Encoding.UTF8, "application/json");
|
||||
try
|
||||
{
|
||||
using HttpResponseMessage response = await _httpClient.PostAsync(EndpointUri, content);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
string body = await response.Content.ReadAsStringAsync();
|
||||
throw new InvalidOperationException(
|
||||
$"Resend email failed: {response.StatusCode} - {body}");
|
||||
}
|
||||
|
||||
_metrics.RecordEmailDelivery("resend", true);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
_metrics.RecordEmailDelivery("resend", false);
|
||||
throw;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
_metrics.RecordEmailDelivery("resend", false);
|
||||
throw;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
_metrics.RecordEmailDelivery("resend", false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeApiKey(string? apiKey)
|
||||
{
|
||||
string normalized = apiKey?.Trim().Trim('"', '\'') ?? string.Empty;
|
||||
const string bearerPrefix = "Bearer ";
|
||||
if (normalized.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
normalized = normalized[bearerPrefix.Length..].Trim();
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Services;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
|
||||
using Socialize.Api.Infrastructure.Configuration;
|
||||
using Socialize.Api.Infrastructure.Emailer.Configuration;
|
||||
using Socialize.Api.Infrastructure.Emailer.Contracts;
|
||||
using Socialize.Api.Infrastructure.Emailer.Services;
|
||||
using Socialize.Api.Infrastructure.Payments.Stripe.Configuration;
|
||||
|
||||
namespace Socialize.Api.Infrastructure;
|
||||
|
||||
internal static class InfrastructureRegistration
|
||||
{
|
||||
public static WebApplicationBuilder AddInfrastructureModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.Configure<WebsiteOptions>(
|
||||
builder.Configuration.GetRequiredSection(WebsiteOptions.SectionName));
|
||||
|
||||
builder.Services.Configure<LocalBlobStorageOptions>(
|
||||
builder.Configuration.GetSection(LocalBlobStorageOptions.SectionName));
|
||||
builder.Services.AddTransient<LocalBlobStorage>();
|
||||
builder.Services.AddTransient<IBlobStorage>(services => services.GetRequiredService<LocalBlobStorage>());
|
||||
builder.Services.Configure<StripeOptions>(
|
||||
builder.Configuration.GetSection(StripeOptions.ConfigurationSection));
|
||||
|
||||
builder.Services.Configure<EmailerOptions>(
|
||||
builder.Configuration.GetSection(EmailerOptions.ConfigurationSection));
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
builder.Services.AddTransient<IEmailSender, LoggerEmailSender>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddTransient<IEmailSender, ResendEmailSender>();
|
||||
}
|
||||
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Socialize.Api.Infrastructure.Emailer.Configuration;
|
||||
|
||||
namespace Socialize.Api.Infrastructure.Observability;
|
||||
|
||||
internal sealed class EmailerConfigurationHealthCheck(
|
||||
IWebHostEnvironment environment,
|
||||
IOptions<EmailerOptions> options)
|
||||
: IHealthCheck
|
||||
{
|
||||
public Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (environment.IsDevelopment())
|
||||
{
|
||||
return Task.FromResult(HealthCheckResult.Healthy("Development email sender logs email instead of delivering it."));
|
||||
}
|
||||
|
||||
EmailerOptions value = options.Value;
|
||||
if (string.IsNullOrWhiteSpace(value.ApiKey) || string.IsNullOrWhiteSpace(value.FromEmail))
|
||||
{
|
||||
return Task.FromResult(HealthCheckResult.Unhealthy("Emailer API key or from address is missing."));
|
||||
}
|
||||
|
||||
return Task.FromResult(HealthCheckResult.Healthy("Emailer configuration is present."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Services;
|
||||
|
||||
namespace Socialize.Api.Infrastructure.Observability;
|
||||
|
||||
internal sealed class LocalBlobStorageHealthCheck(
|
||||
LocalBlobStorage blobStorage,
|
||||
IOptions<LocalBlobStorageOptions> options)
|
||||
: IHealthCheck
|
||||
{
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string rootPath = blobStorage.GetRootPath();
|
||||
if (string.IsNullOrWhiteSpace(options.Value.RequestPath))
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("Local blob storage request path is not configured.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(rootPath);
|
||||
string probePath = Path.Combine(rootPath, ".healthcheck");
|
||||
await File.WriteAllTextAsync(
|
||||
probePath,
|
||||
DateTimeOffset.UtcNow.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
|
||||
cancellationToken);
|
||||
File.Delete(probePath);
|
||||
|
||||
return HealthCheckResult.Healthy("Local blob storage is writable.");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("Local blob storage is not writable.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Npgsql;
|
||||
using OpenTelemetry.Logs;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace Socialize.Api.Infrastructure.Observability;
|
||||
|
||||
internal static class ObservabilityRegistration
|
||||
{
|
||||
private const string DefaultServiceName = "socialize-api";
|
||||
|
||||
public static WebApplicationBuilder AddObservability(this WebApplicationBuilder builder)
|
||||
{
|
||||
string serviceName = GetConfigurationValue(builder.Configuration, "OTEL_SERVICE_NAME", DefaultServiceName);
|
||||
string serviceVersion = typeof(Program).Assembly.GetName().Version?.ToString() ?? "unknown";
|
||||
|
||||
builder.Logging.Configure(options =>
|
||||
{
|
||||
options.ActivityTrackingOptions =
|
||||
ActivityTrackingOptions.TraceId |
|
||||
ActivityTrackingOptions.SpanId |
|
||||
ActivityTrackingOptions.ParentId;
|
||||
});
|
||||
|
||||
builder.Logging.AddJsonConsole(options =>
|
||||
{
|
||||
options.IncludeScopes = true;
|
||||
options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ";
|
||||
options.UseUtcTimestamp = true;
|
||||
options.JsonWriterOptions = new JsonWriterOptions { Indented = false };
|
||||
});
|
||||
|
||||
bool otlpEnabled = HasOtlpEndpoint(builder.Configuration);
|
||||
if (otlpEnabled)
|
||||
{
|
||||
builder.Logging.AddOpenTelemetry(options =>
|
||||
{
|
||||
options.IncludeFormattedMessage = true;
|
||||
options.IncludeScopes = true;
|
||||
options.ParseStateValues = true;
|
||||
options.SetResourceBuilder(BuildResource(serviceName, serviceVersion));
|
||||
options.AddOtlpExporter();
|
||||
});
|
||||
}
|
||||
|
||||
builder.Services.AddSingleton<SocializeMetrics>();
|
||||
builder.Services.AddHostedService<WorkflowHealthSamplerService>();
|
||||
builder.Services
|
||||
.AddOpenTelemetry()
|
||||
.ConfigureResource(resource => resource.AddService(
|
||||
serviceName,
|
||||
serviceVersion: serviceVersion))
|
||||
.WithTracing(tracing =>
|
||||
{
|
||||
tracing
|
||||
.AddSource(SocializeMetrics.ActivitySourceName)
|
||||
.AddAspNetCoreInstrumentation(options =>
|
||||
{
|
||||
options.RecordException = true;
|
||||
})
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddNpgsql();
|
||||
|
||||
if (otlpEnabled)
|
||||
{
|
||||
tracing.AddOtlpExporter();
|
||||
}
|
||||
})
|
||||
.WithMetrics(metrics =>
|
||||
{
|
||||
metrics
|
||||
.AddMeter(SocializeMetrics.MeterName)
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation();
|
||||
|
||||
if (otlpEnabled)
|
||||
{
|
||||
metrics.AddOtlpExporter();
|
||||
}
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static IApplicationBuilder UseObservabilityLoggingScope(this IApplicationBuilder app)
|
||||
{
|
||||
return app.UseMiddleware<RequestLoggingScopeMiddleware>();
|
||||
}
|
||||
|
||||
public static IEndpointRouteBuilder MapObservabilityHealthChecks(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapHealthChecks(
|
||||
"/health",
|
||||
new HealthCheckOptions { ResponseWriter = WriteHealthResponseAsync });
|
||||
endpoints.MapHealthChecks(
|
||||
"/health/live",
|
||||
new HealthCheckOptions
|
||||
{
|
||||
Predicate = registration => registration.Tags.Contains("live", StringComparer.Ordinal),
|
||||
ResponseWriter = WriteHealthResponseAsync,
|
||||
});
|
||||
endpoints.MapHealthChecks(
|
||||
"/health/ready",
|
||||
new HealthCheckOptions
|
||||
{
|
||||
Predicate = registration => registration.Tags.Contains("ready", StringComparer.Ordinal),
|
||||
ResponseWriter = WriteHealthResponseAsync,
|
||||
});
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static ResourceBuilder BuildResource(string serviceName, string serviceVersion)
|
||||
{
|
||||
return ResourceBuilder.CreateDefault().AddService(
|
||||
serviceName,
|
||||
serviceVersion: serviceVersion);
|
||||
}
|
||||
|
||||
private static bool HasOtlpEndpoint(ConfigurationManager configuration)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]) ||
|
||||
!string.IsNullOrWhiteSpace(configuration["Otlp:Endpoint"]);
|
||||
}
|
||||
|
||||
private static string GetConfigurationValue(
|
||||
ConfigurationManager configuration,
|
||||
string key,
|
||||
string fallback)
|
||||
{
|
||||
string? value = configuration[key];
|
||||
return string.IsNullOrWhiteSpace(value) ? fallback : value;
|
||||
}
|
||||
|
||||
private static async Task WriteHealthResponseAsync(HttpContext context, HealthReport report)
|
||||
{
|
||||
context.Response.ContentType = "application/json";
|
||||
|
||||
var response = new
|
||||
{
|
||||
status = report.Status.ToString(),
|
||||
checks = report.Entries.Select(entry => new
|
||||
{
|
||||
name = entry.Key,
|
||||
status = entry.Value.Status.ToString(),
|
||||
description = entry.Value.Description,
|
||||
duration = entry.Value.Duration.TotalMilliseconds,
|
||||
}),
|
||||
duration = report.TotalDuration.TotalMilliseconds,
|
||||
};
|
||||
|
||||
await JsonSerializer.SerializeAsync(
|
||||
context.Response.Body,
|
||||
response,
|
||||
cancellationToken: context.RequestAborted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Diagnostics;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
|
||||
namespace Socialize.Api.Infrastructure.Observability;
|
||||
|
||||
internal sealed class RequestLoggingScopeMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<RequestLoggingScopeMiddleware> logger)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
Dictionary<string, object?> scope = new()
|
||||
{
|
||||
["trace_id"] = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier,
|
||||
["span_id"] = Activity.Current?.SpanId.ToString(),
|
||||
["http.method"] = context.Request.Method,
|
||||
["url.path"] = context.Request.Path.Value,
|
||||
};
|
||||
|
||||
if (context.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
scope["user.id"] = context.User.GetUserId();
|
||||
scope["user.email"] = context.User.GetEmail();
|
||||
}
|
||||
|
||||
AddGuidIfPresent(scope, "organization.id", context, "organizationId");
|
||||
AddGuidIfPresent(scope, "workspace.id", context, "workspaceId");
|
||||
AddGuidIfPresent(scope, "client.id", context, "clientId");
|
||||
AddGuidIfPresent(scope, "campaign.id", context, "campaignId");
|
||||
AddGuidIfPresent(scope, "content_item.id", context, "contentItemId");
|
||||
|
||||
using IDisposable? _ = logger.BeginScope(scope);
|
||||
await next(context);
|
||||
}
|
||||
|
||||
private static void AddGuidIfPresent(
|
||||
Dictionary<string, object?> scope,
|
||||
string scopeKey,
|
||||
HttpContext context,
|
||||
string requestKey)
|
||||
{
|
||||
string? value = GetRouteOrQueryValue(context, requestKey);
|
||||
if (Guid.TryParse(value, out Guid id))
|
||||
{
|
||||
scope[scopeKey] = id;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetRouteOrQueryValue(HttpContext context, string key)
|
||||
{
|
||||
object? routeValue = context.Request.RouteValues[key];
|
||||
if (routeValue is not null)
|
||||
{
|
||||
return Convert.ToString(routeValue, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
return context.Request.Query.TryGetValue(key, out Microsoft.Extensions.Primitives.StringValues queryValue)
|
||||
? queryValue.ToString()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace Socialize.Api.Infrastructure.Observability;
|
||||
|
||||
internal sealed class SocializeMetrics : IDisposable
|
||||
{
|
||||
public const string MeterName = "Socialize.Api";
|
||||
public const string ActivitySourceName = "Socialize.Api";
|
||||
|
||||
private readonly Counter<long> _approvalDecisionCounter;
|
||||
private readonly Counter<long> _backgroundJobRunCounter;
|
||||
private readonly Counter<long> _blobStorageOperationCounter;
|
||||
private readonly Counter<long> _commentCreatedCounter;
|
||||
private readonly Counter<long> _contentItemCreatedCounter;
|
||||
private readonly Counter<long> _emailDeliveryCounter;
|
||||
private readonly Counter<long> _feedbackSubmittedCounter;
|
||||
private readonly Counter<long> _loginAttemptCounter;
|
||||
private readonly Counter<long> _organizationCreatedCounter;
|
||||
private readonly Counter<long> _workspaceCreatedCounter;
|
||||
private readonly Counter<long> _workspaceInviteCreatedCounter;
|
||||
private readonly object _workflowHealthLock = new();
|
||||
private WorkflowHealthSnapshot _workflowHealthSnapshot = WorkflowHealthSnapshot.Empty;
|
||||
|
||||
public SocializeMetrics()
|
||||
{
|
||||
Meter = new Meter(MeterName);
|
||||
ActivitySource = new ActivitySource(ActivitySourceName);
|
||||
|
||||
_loginAttemptCounter = Meter.CreateCounter<long>(
|
||||
"socialize.login.attempts",
|
||||
description: "Login attempts partitioned by outcome.");
|
||||
_organizationCreatedCounter = Meter.CreateCounter<long>(
|
||||
"socialize.organizations.created",
|
||||
description: "Organizations created.");
|
||||
_workspaceCreatedCounter = Meter.CreateCounter<long>(
|
||||
"socialize.workspaces.created",
|
||||
description: "Workspaces created.");
|
||||
_contentItemCreatedCounter = Meter.CreateCounter<long>(
|
||||
"socialize.content_items.created",
|
||||
description: "Content items created.");
|
||||
_commentCreatedCounter = Meter.CreateCounter<long>(
|
||||
"socialize.comments.created",
|
||||
description: "Comments created.");
|
||||
_approvalDecisionCounter = Meter.CreateCounter<long>(
|
||||
"socialize.approval_decisions.submitted",
|
||||
description: "Approval decisions submitted.");
|
||||
_feedbackSubmittedCounter = Meter.CreateCounter<long>(
|
||||
"socialize.feedback.submitted",
|
||||
description: "Feedback reports submitted.");
|
||||
_workspaceInviteCreatedCounter = Meter.CreateCounter<long>(
|
||||
"socialize.workspace_invites.created",
|
||||
description: "Workspace invites created.");
|
||||
_emailDeliveryCounter = Meter.CreateCounter<long>(
|
||||
"socialize.email.delivery",
|
||||
description: "Email delivery attempts partitioned by outcome and provider.");
|
||||
_blobStorageOperationCounter = Meter.CreateCounter<long>(
|
||||
"socialize.blob_storage.operations",
|
||||
description: "Blob storage operations partitioned by operation and outcome.");
|
||||
_backgroundJobRunCounter = Meter.CreateCounter<long>(
|
||||
"socialize.background_job.runs",
|
||||
description: "Background job runs partitioned by job and outcome.");
|
||||
|
||||
Meter.CreateObservableGauge(
|
||||
"socialize.workflow.content_items",
|
||||
ObserveContentItemCounts,
|
||||
description: "Current content item counts by status.");
|
||||
Meter.CreateObservableGauge(
|
||||
"socialize.workflow.feedback_reports",
|
||||
ObserveFeedbackReportCounts,
|
||||
description: "Current feedback report counts by status.");
|
||||
Meter.CreateObservableGauge(
|
||||
"socialize.workflow.pending_invites",
|
||||
ObservePendingInviteCount,
|
||||
description: "Current pending workspace invite count.");
|
||||
Meter.CreateObservableGauge(
|
||||
"socialize.workflow.stale_in_approval",
|
||||
ObserveStaleApprovalCount,
|
||||
description: "Current count of content items in approval longer than the configured stale threshold.");
|
||||
Meter.CreateObservableGauge(
|
||||
"socialize.workflow.active_workspaces",
|
||||
ObserveActiveWorkspaceCounts,
|
||||
description: "Current active workspace counts by observation window.");
|
||||
}
|
||||
|
||||
public Meter Meter { get; }
|
||||
|
||||
public ActivitySource ActivitySource { get; }
|
||||
|
||||
public void RecordLoginAttempt(bool succeeded, string reason)
|
||||
{
|
||||
_loginAttemptCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("outcome", succeeded ? "success" : "failure"),
|
||||
new KeyValuePair<string, object?>("reason", reason));
|
||||
}
|
||||
|
||||
public void RecordOrganizationCreated(Guid organizationId)
|
||||
{
|
||||
_organizationCreatedCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("organization.id", organizationId));
|
||||
}
|
||||
|
||||
public void RecordWorkspaceCreated(Guid organizationId, Guid workspaceId)
|
||||
{
|
||||
_workspaceCreatedCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("organization.id", organizationId),
|
||||
new KeyValuePair<string, object?>("workspace.id", workspaceId));
|
||||
}
|
||||
|
||||
public void RecordContentItemCreated(Guid workspaceId)
|
||||
{
|
||||
_contentItemCreatedCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("workspace.id", workspaceId));
|
||||
}
|
||||
|
||||
public void RecordCommentCreated(Guid workspaceId, bool hasAttachment)
|
||||
{
|
||||
_commentCreatedCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("workspace.id", workspaceId),
|
||||
new KeyValuePair<string, object?>("has_attachment", hasAttachment));
|
||||
}
|
||||
|
||||
public void RecordApprovalDecisionSubmitted(Guid workspaceId, string decision)
|
||||
{
|
||||
_approvalDecisionCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("workspace.id", workspaceId),
|
||||
new KeyValuePair<string, object?>("decision", decision));
|
||||
}
|
||||
|
||||
public void RecordFeedbackSubmitted(string type, Guid? workspaceId)
|
||||
{
|
||||
_feedbackSubmittedCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("feedback.type", type),
|
||||
new KeyValuePair<string, object?>("workspace.id", workspaceId?.ToString() ?? "none"));
|
||||
}
|
||||
|
||||
public void RecordWorkspaceInviteCreated(Guid workspaceId, string role)
|
||||
{
|
||||
_workspaceInviteCreatedCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("workspace.id", workspaceId),
|
||||
new KeyValuePair<string, object?>("role", role));
|
||||
}
|
||||
|
||||
public void RecordEmailDelivery(string provider, bool succeeded)
|
||||
{
|
||||
_emailDeliveryCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("provider", provider),
|
||||
new KeyValuePair<string, object?>("outcome", succeeded ? "success" : "failure"));
|
||||
}
|
||||
|
||||
public void RecordBlobStorageOperation(string operation, bool succeeded)
|
||||
{
|
||||
_blobStorageOperationCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("operation", operation),
|
||||
new KeyValuePair<string, object?>("outcome", succeeded ? "success" : "failure"));
|
||||
}
|
||||
|
||||
public void RecordBackgroundJobRun(string job, bool succeeded)
|
||||
{
|
||||
_backgroundJobRunCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("job", job),
|
||||
new KeyValuePair<string, object?>("outcome", succeeded ? "success" : "failure"));
|
||||
}
|
||||
|
||||
public void UpdateWorkflowHealth(WorkflowHealthSnapshot snapshot)
|
||||
{
|
||||
lock (_workflowHealthLock)
|
||||
{
|
||||
_workflowHealthSnapshot = snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Meter.Dispose();
|
||||
ActivitySource.Dispose();
|
||||
}
|
||||
|
||||
private Measurement<int>[] ObserveContentItemCounts()
|
||||
{
|
||||
WorkflowHealthSnapshot snapshot = GetWorkflowHealthSnapshot();
|
||||
return snapshot.ContentItemsByStatus
|
||||
.Select(pair => new Measurement<int>(
|
||||
pair.Value,
|
||||
new KeyValuePair<string, object?>("status", pair.Key)))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private Measurement<int>[] ObserveFeedbackReportCounts()
|
||||
{
|
||||
WorkflowHealthSnapshot snapshot = GetWorkflowHealthSnapshot();
|
||||
return snapshot.FeedbackReportsByStatus
|
||||
.Select(pair => new Measurement<int>(
|
||||
pair.Value,
|
||||
new KeyValuePair<string, object?>("status", pair.Key)))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private Measurement<int> ObservePendingInviteCount()
|
||||
{
|
||||
return new Measurement<int>(GetWorkflowHealthSnapshot().PendingInviteCount);
|
||||
}
|
||||
|
||||
private Measurement<int> ObserveStaleApprovalCount()
|
||||
{
|
||||
return new Measurement<int>(GetWorkflowHealthSnapshot().StaleInApprovalCount);
|
||||
}
|
||||
|
||||
private Measurement<int>[] ObserveActiveWorkspaceCounts()
|
||||
{
|
||||
WorkflowHealthSnapshot snapshot = GetWorkflowHealthSnapshot();
|
||||
return
|
||||
[
|
||||
new Measurement<int>(
|
||||
snapshot.ActiveWorkspaces24Hours,
|
||||
new KeyValuePair<string, object?>("window", "24h")),
|
||||
new Measurement<int>(
|
||||
snapshot.ActiveWorkspaces7Days,
|
||||
new KeyValuePair<string, object?>("window", "7d")),
|
||||
];
|
||||
}
|
||||
|
||||
private WorkflowHealthSnapshot GetWorkflowHealthSnapshot()
|
||||
{
|
||||
lock (_workflowHealthLock)
|
||||
{
|
||||
return _workflowHealthSnapshot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record WorkflowHealthSnapshot(
|
||||
IReadOnlyDictionary<string, int> ContentItemsByStatus,
|
||||
IReadOnlyDictionary<string, int> FeedbackReportsByStatus,
|
||||
int PendingInviteCount,
|
||||
int StaleInApprovalCount,
|
||||
int ActiveWorkspaces24Hours,
|
||||
int ActiveWorkspaces7Days)
|
||||
{
|
||||
public static WorkflowHealthSnapshot Empty { get; } = new(
|
||||
new Dictionary<string, int>(StringComparer.Ordinal),
|
||||
new Dictionary<string, int>(StringComparer.Ordinal),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Modules.Feedback.Data;
|
||||
using Socialize.Api.Modules.Workspaces.Data;
|
||||
|
||||
namespace Socialize.Api.Infrastructure.Observability;
|
||||
|
||||
internal sealed class WorkflowHealthSamplerService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
SocializeMetrics metrics,
|
||||
ILogger<WorkflowHealthSamplerService> logger)
|
||||
: BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan SampleInterval = TimeSpan.FromMinutes(5);
|
||||
private static readonly TimeSpan StaleApprovalThreshold = TimeSpan.FromDays(3);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await SampleAsync(stoppingToken);
|
||||
|
||||
using PeriodicTimer timer = new(SampleInterval);
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await timer.WaitForNextTickAsync(stoppingToken);
|
||||
await SampleAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException ex) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogDebug(ex, "Workflow health sampler stopped.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SampleAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using IServiceScope scope = scopeFactory.CreateScope();
|
||||
AppDbContext dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
DateTimeOffset staleApprovalCutoff = now.Subtract(StaleApprovalThreshold);
|
||||
DateTimeOffset active24HourCutoff = now.AddHours(-24);
|
||||
DateTimeOffset active7DayCutoff = now.AddDays(-7);
|
||||
|
||||
Dictionary<string, int> contentItemsByStatus = await dbContext.ContentItems
|
||||
.GroupBy(item => item.Status)
|
||||
.Select(group => new { Status = group.Key, Count = group.Count() })
|
||||
.ToDictionaryAsync(group => group.Status, group => group.Count, StringComparer.Ordinal, stoppingToken);
|
||||
|
||||
Dictionary<string, int> feedbackReportsByStatus = await dbContext.FeedbackReports
|
||||
.GroupBy(report => report.Status)
|
||||
.Select(group => new { Status = group.Key, Count = group.Count() })
|
||||
.ToDictionaryAsync(
|
||||
group => group.Status == FeedbackStatus.WontDo ? "WontDo" : group.Status.ToString(),
|
||||
group => group.Count,
|
||||
StringComparer.Ordinal,
|
||||
stoppingToken);
|
||||
|
||||
int pendingInviteCount = await dbContext.WorkspaceInvites
|
||||
.CountAsync(invite => invite.Status == WorkspaceInviteStatuses.Pending, stoppingToken);
|
||||
|
||||
int staleInApprovalCount = await dbContext.ContentItems
|
||||
.CountAsync(
|
||||
item => item.Status == "In approval" && item.CreatedAt <= staleApprovalCutoff,
|
||||
stoppingToken);
|
||||
|
||||
int activeWorkspaces24Hours = await dbContext.ContentItemActivityEntries
|
||||
.Where(entry => entry.CreatedAt >= active24HourCutoff)
|
||||
.Select(entry => entry.WorkspaceId)
|
||||
.Distinct()
|
||||
.CountAsync(stoppingToken);
|
||||
|
||||
int activeWorkspaces7Days = await dbContext.ContentItemActivityEntries
|
||||
.Where(entry => entry.CreatedAt >= active7DayCutoff)
|
||||
.Select(entry => entry.WorkspaceId)
|
||||
.Distinct()
|
||||
.CountAsync(stoppingToken);
|
||||
|
||||
metrics.UpdateWorkflowHealth(new WorkflowHealthSnapshot(
|
||||
contentItemsByStatus,
|
||||
feedbackReportsByStatus,
|
||||
pendingInviteCount,
|
||||
staleInApprovalCount,
|
||||
activeWorkspaces24Hours,
|
||||
activeWorkspaces7Days));
|
||||
metrics.RecordBackgroundJobRun(nameof(WorkflowHealthSamplerService), true);
|
||||
}
|
||||
catch (OperationCanceledException ex) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogDebug(ex, "Workflow health sampler stopped.");
|
||||
}
|
||||
#pragma warning disable CA1031
|
||||
catch (Exception ex)
|
||||
{
|
||||
metrics.RecordBackgroundJobRun(nameof(WorkflowHealthSamplerService), false);
|
||||
logger.LogError(ex, "Workflow health sampling failed.");
|
||||
}
|
||||
#pragma warning restore CA1031
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Socialize.Infrastructure.Payments.Stripe.Configuration;
|
||||
namespace Socialize.Api.Infrastructure.Payments.Stripe.Configuration;
|
||||
|
||||
public class StripeOptions
|
||||
internal class StripeOptions
|
||||
{
|
||||
public const string ConfigurationSection = "Stripe";
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Security.Claims;
|
||||
using Socialize.Api.Modules.Identity.Contracts;
|
||||
using Socialize.Api.Modules.Organizations.Services;
|
||||
|
||||
namespace Socialize.Api.Infrastructure.Security;
|
||||
|
||||
internal sealed class AccessScopeService(
|
||||
OrganizationAccessService organizationAccessService)
|
||||
{
|
||||
public static bool IsManager(ClaimsPrincipal user)
|
||||
{
|
||||
return user.IsInRole(KnownRoles.Administrator) || user.IsInRole(KnownRoles.Manager);
|
||||
}
|
||||
|
||||
public static bool IsProvider(ClaimsPrincipal user)
|
||||
{
|
||||
return user.IsInRole(KnownRoles.Provider);
|
||||
}
|
||||
|
||||
public static bool IsClient(ClaimsPrincipal user)
|
||||
{
|
||||
return user.IsInRole(KnownRoles.Client);
|
||||
}
|
||||
|
||||
public static bool CanAccessWorkspace(ClaimsPrincipal user, Guid workspaceId)
|
||||
{
|
||||
return user.GetWorkspaceScopeIds().Contains(workspaceId);
|
||||
}
|
||||
|
||||
public static bool CanManageWorkspace(ClaimsPrincipal user, Guid workspaceId)
|
||||
{
|
||||
return IsManager(user) && CanAccessWorkspace(user, workspaceId);
|
||||
}
|
||||
|
||||
public static bool CanAccessClient(ClaimsPrincipal user, Guid workspaceId, Guid clientId)
|
||||
{
|
||||
return CanAccessWorkspace(user, workspaceId) &&
|
||||
(IsManager(user) || user.GetClientScopeIds().Contains(clientId));
|
||||
}
|
||||
|
||||
public static bool CanAccessCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
|
||||
{
|
||||
return CanAccessClient(user, workspaceId, clientId) &&
|
||||
(IsManager(user) || user.GetCampaignScopeIds().Contains(campaignId));
|
||||
}
|
||||
|
||||
public static bool CanContributeToCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
|
||||
{
|
||||
return IsManager(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId)
|
||||
|| IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId);
|
||||
}
|
||||
|
||||
public static bool CanReviewContent(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
|
||||
{
|
||||
return IsManager(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId)
|
||||
|| IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId)
|
||||
|| IsClient(user) && CanAccessClient(user, workspaceId, clientId);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyCollection<Guid>> GetAccessibleWorkspaceIdsAsync(
|
||||
ClaimsPrincipal user,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return organizationAccessService.GetAccessibleWorkspaceIdsAsync(user, ct);
|
||||
}
|
||||
|
||||
public async Task<bool> CanAccessWorkspaceAsync(
|
||||
ClaimsPrincipal user,
|
||||
Guid workspaceId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return user.GetWorkspaceScopeIds().Contains(workspaceId)
|
||||
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
||||
user,
|
||||
workspaceId,
|
||||
OrganizationPermissions.AccessOwnedWorkspaces,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task<bool> CanManageWorkspaceAsync(
|
||||
ClaimsPrincipal user,
|
||||
Guid workspaceId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return CanManageWorkspace(user, workspaceId)
|
||||
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
||||
user,
|
||||
workspaceId,
|
||||
OrganizationPermissions.ManageWorkspaces,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task<bool> CanCreateWorkspaceAsync(
|
||||
ClaimsPrincipal user,
|
||||
Guid organizationId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return await organizationAccessService.HasOrganizationPermissionAsync(
|
||||
user,
|
||||
organizationId,
|
||||
OrganizationPermissions.CreateWorkspaces,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task<bool> CanAccessClientAsync(
|
||||
ClaimsPrincipal user,
|
||||
Guid workspaceId,
|
||||
Guid clientId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
||||
user,
|
||||
workspaceId,
|
||||
OrganizationPermissions.AccessOwnedWorkspaces,
|
||||
ct))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return user.GetWorkspaceScopeIds().Contains(workspaceId) && user.GetClientScopeIds().Contains(clientId);
|
||||
}
|
||||
|
||||
public async Task<bool> CanAccessCampaignAsync(
|
||||
ClaimsPrincipal user,
|
||||
Guid workspaceId,
|
||||
Guid clientId,
|
||||
Guid campaignId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
||||
user,
|
||||
workspaceId,
|
||||
OrganizationPermissions.AccessOwnedWorkspaces,
|
||||
ct))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return await CanAccessClientAsync(user, workspaceId, clientId, ct) &&
|
||||
user.GetCampaignScopeIds().Contains(campaignId);
|
||||
}
|
||||
|
||||
public async Task<bool> CanContributeToCampaignAsync(
|
||||
ClaimsPrincipal user,
|
||||
Guid workspaceId,
|
||||
Guid clientId,
|
||||
Guid? campaignId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!campaignId.HasValue)
|
||||
{
|
||||
return IsManager(user) && await CanAccessClientAsync(user, workspaceId, clientId, ct)
|
||||
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
||||
user,
|
||||
workspaceId,
|
||||
OrganizationPermissions.ManageWorkspaces,
|
||||
ct)
|
||||
|| IsProvider(user) && await CanAccessClientAsync(user, workspaceId, clientId, ct);
|
||||
}
|
||||
|
||||
return IsManager(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId.Value, ct)
|
||||
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
||||
user,
|
||||
workspaceId,
|
||||
OrganizationPermissions.ManageWorkspaces,
|
||||
ct)
|
||||
|| IsProvider(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId.Value, ct);
|
||||
}
|
||||
|
||||
public async Task<bool> CanReviewContentAsync(
|
||||
ClaimsPrincipal user,
|
||||
Guid workspaceId,
|
||||
Guid clientId,
|
||||
Guid? campaignId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!campaignId.HasValue)
|
||||
{
|
||||
return IsManager(user) && await CanAccessClientAsync(user, workspaceId, clientId, ct)
|
||||
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
||||
user,
|
||||
workspaceId,
|
||||
OrganizationPermissions.AccessOwnedWorkspaces,
|
||||
ct)
|
||||
|| IsProvider(user) && await CanAccessClientAsync(user, workspaceId, clientId, ct)
|
||||
|| IsClient(user) && await CanAccessClientAsync(user, workspaceId, clientId, ct);
|
||||
}
|
||||
|
||||
return IsManager(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId.Value, ct)
|
||||
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
||||
user,
|
||||
workspaceId,
|
||||
OrganizationPermissions.AccessOwnedWorkspaces,
|
||||
ct)
|
||||
|| IsProvider(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId.Value, ct)
|
||||
|| IsClient(user) && await CanAccessClientAsync(user, workspaceId, clientId, ct);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
using System.Security.Claims;
|
||||
using System.Globalization;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Socialize.Infrastructure.Security;
|
||||
namespace Socialize.Api.Infrastructure.Security;
|
||||
|
||||
public static class ClaimsPrincipalExtensions
|
||||
internal static class ClaimsPrincipalExtensions
|
||||
{
|
||||
public static IReadOnlyCollection<Guid> GetScopeIds(this ClaimsPrincipal claims, string key)
|
||||
{
|
||||
@@ -23,9 +24,9 @@ public static class ClaimsPrincipalExtensions
|
||||
return claims.GetScopeIds(KnownClaims.ClientScope);
|
||||
}
|
||||
|
||||
public static IReadOnlyCollection<Guid> GetProjectScopeIds(this ClaimsPrincipal claims)
|
||||
public static IReadOnlyCollection<Guid> GetCampaignScopeIds(this ClaimsPrincipal claims)
|
||||
{
|
||||
return claims.GetScopeIds(KnownClaims.ProjectScope);
|
||||
return claims.GetScopeIds(KnownClaims.CampaignScope);
|
||||
}
|
||||
|
||||
public static string? GetPersona(this ClaimsPrincipal claims)
|
||||
@@ -81,11 +82,11 @@ public static class ClaimsPrincipalExtensions
|
||||
|
||||
if (claim is null)
|
||||
{
|
||||
throw new MissingClaimException(key);
|
||||
throw MissingClaimException.ForClaim(key);
|
||||
}
|
||||
|
||||
return typeof(TValue) == typeof(Guid)
|
||||
? Guid.Parse(claim.Value)
|
||||
: Convert.ChangeType(claim.Value, typeof(TValue));
|
||||
: Convert.ChangeType(claim.Value, typeof(TValue), CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,9 @@ using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Socialize.Infrastructure.Security;
|
||||
namespace Socialize.Api.Infrastructure.Security;
|
||||
|
||||
public static class JwtTokenHelper
|
||||
internal static class JwtTokenHelper
|
||||
{
|
||||
public static string GenerateJwtToken(
|
||||
TimeSpan expiresIn,
|
||||
@@ -1,11 +1,11 @@
|
||||
namespace Socialize.Infrastructure.Security;
|
||||
namespace Socialize.Api.Infrastructure.Security;
|
||||
|
||||
public static class KnownClaims
|
||||
internal 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 CampaignScope = "campaign";
|
||||
public const string Persona = "persona";
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Socialize.Api.Infrastructure.Security;
|
||||
|
||||
public class MissingClaimException : Exception
|
||||
{
|
||||
public MissingClaimException()
|
||||
{
|
||||
}
|
||||
|
||||
public MissingClaimException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public MissingClaimException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
|
||||
internal static MissingClaimException ForClaim(string claimName)
|
||||
{
|
||||
return new MissingClaimException($"Claim '{claimName}' is missing.");
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Socialize.Infrastructure.Security;
|
||||
namespace Socialize.Api.Infrastructure.Security;
|
||||
|
||||
// If we need to add special characters we can alternate between 2 pools.
|
||||
public static class PasswordGenerator
|
||||
internal static class PasswordGenerator
|
||||
{
|
||||
private const string LowerLetters = "abcdefghijklmnopqrstuvwxyz";
|
||||
private const string UpperLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
private const string Numbers = "0123456789";
|
||||
private const string SpecialCharacters = "!@#$%^&*()_+-=[];',./`~{}|:\"<>?";
|
||||
|
||||
private static readonly Random Random = new();
|
||||
|
||||
public static string Next(
|
||||
int length = 15,
|
||||
bool requireNumber = true,
|
||||
@@ -23,7 +21,7 @@ public static class PasswordGenerator
|
||||
// Create pools based on the requirements
|
||||
StringBuilder characterPool = new();
|
||||
|
||||
if (requireNumber)
|
||||
if (requireLowercase)
|
||||
{
|
||||
characterPool.Append(LowerLetters);
|
||||
}
|
||||
@@ -51,22 +49,22 @@ public static class PasswordGenerator
|
||||
|
||||
if (requireLowercase)
|
||||
{
|
||||
password[index++] = LowerLetters[Random.Next(LowerLetters.Length)];
|
||||
password[index++] = LowerLetters[RandomNumberGenerator.GetInt32(LowerLetters.Length)];
|
||||
}
|
||||
|
||||
if (requireCapital)
|
||||
{
|
||||
password[index++] = UpperLetters[Random.Next(UpperLetters.Length)];
|
||||
password[index++] = UpperLetters[RandomNumberGenerator.GetInt32(UpperLetters.Length)];
|
||||
}
|
||||
|
||||
if (requireNumber)
|
||||
{
|
||||
password[index++] = Numbers[Random.Next(Numbers.Length)];
|
||||
password[index++] = Numbers[RandomNumberGenerator.GetInt32(Numbers.Length)];
|
||||
}
|
||||
|
||||
if (requireSpecialCharacter)
|
||||
{
|
||||
password[index++] = SpecialCharacters[Random.Next(SpecialCharacters.Length)];
|
||||
password[index++] = SpecialCharacters[RandomNumberGenerator.GetInt32(SpecialCharacters.Length)];
|
||||
}
|
||||
|
||||
// Fill the rest with the password
|
||||
@@ -85,7 +83,7 @@ public static class PasswordGenerator
|
||||
{
|
||||
for (int i = array.Length - 1; i > 0; i--)
|
||||
{
|
||||
int j = Random.Next(i + 1);
|
||||
int j = RandomNumberGenerator.GetInt32(i + 1);
|
||||
(array[i], array[j]) = (array[j], array[i]); // Swap elements
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Socialize.Infrastructure.Security;
|
||||
namespace Socialize.Api.Infrastructure.Security;
|
||||
|
||||
public static class RefreshTokenGenerator
|
||||
internal static class RefreshTokenGenerator
|
||||
{
|
||||
public static string Next()
|
||||
{
|
||||
@@ -1,19 +1,39 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Claims;
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Contracts;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Identity.Contracts;
|
||||
using Socialize.Api.Modules.Identity.Data;
|
||||
using Socialize.Api.Modules.Assets.Data;
|
||||
using Socialize.Api.Modules.Approvals.Data;
|
||||
using Socialize.Api.Modules.Channels.Data;
|
||||
using Socialize.Api.Modules.Comments.Data;
|
||||
using Socialize.Api.Modules.ContentItems.Data;
|
||||
using Socialize.Api.Modules.Clients.Data;
|
||||
using Socialize.Api.Modules.Notifications.Data;
|
||||
using Socialize.Api.Modules.Campaigns.Data;
|
||||
using Socialize.Api.Modules.Organizations.Data;
|
||||
using Socialize.Api.Modules.Organizations.Services;
|
||||
using Socialize.Api.Modules.Workspaces.Data;
|
||||
using Socialize.Api.Modules.Workspaces.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Socialize.Infrastructure.Development;
|
||||
namespace Socialize.Api.Infrastructure.TestData;
|
||||
|
||||
public static class DevelopmentSeedExtensions
|
||||
#pragma warning disable S1075 // Test data intentionally uses representative external URLs.
|
||||
|
||||
internal static class TestDataSeedExtensions
|
||||
{
|
||||
private static readonly Guid OrganizationId = Guid.Parse("99999999-9999-9999-9999-999999999999");
|
||||
private static readonly Guid WorkspaceId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
private static readonly Guid AtlasWorkspaceId = Guid.Parse("11111111-1111-1111-1111-222222222222");
|
||||
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 ScopedCampaignId = Guid.Parse("33333333-3333-3333-3333-333333333333");
|
||||
private static readonly Guid HiddenCampaignId = Guid.Parse("33333333-3333-3333-3333-444444444444");
|
||||
private static readonly Guid LumaInstagramChannelId = Guid.Parse("33333333-3333-3333-3333-000000000001");
|
||||
private static readonly Guid LumaTikTokChannelId = Guid.Parse("33333333-3333-3333-3333-000000000002");
|
||||
private static readonly Guid AtlasInstagramChannelId = Guid.Parse("33333333-3333-3333-3333-000000000003");
|
||||
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");
|
||||
@@ -21,34 +41,20 @@ public static class DevelopmentSeedExtensions
|
||||
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,
|
||||
public static async Task<IServiceProvider> SeedTestDataAsync(
|
||||
this IServiceProvider services,
|
||||
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;
|
||||
}
|
||||
|
||||
using IServiceScope scope = services.CreateScope();
|
||||
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",
|
||||
password: "Manager1!",
|
||||
alias: "Northstar Manager",
|
||||
firstname: "Morgan",
|
||||
lastname: "Reid",
|
||||
@@ -64,7 +70,7 @@ public static class DevelopmentSeedExtensions
|
||||
id: Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
|
||||
username: "client",
|
||||
email: "client@socialize.local",
|
||||
password: "client",
|
||||
password: "Client1!",
|
||||
alias: "Sofia Martin",
|
||||
firstname: "Sofia",
|
||||
lastname: "Martin",
|
||||
@@ -81,7 +87,7 @@ public static class DevelopmentSeedExtensions
|
||||
id: Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"),
|
||||
username: "provider",
|
||||
email: "provider@socialize.local",
|
||||
password: "provider",
|
||||
password: "Provider1!",
|
||||
alias: "Alex Studio",
|
||||
firstname: "Alex",
|
||||
lastname: "Studio",
|
||||
@@ -91,9 +97,30 @@ public static class DevelopmentSeedExtensions
|
||||
[
|
||||
new Claim(KnownClaims.WorkspaceScope, WorkspaceId.ToString()),
|
||||
new Claim(KnownClaims.ClientScope, ScopedClientId.ToString()),
|
||||
new Claim(KnownClaims.ProjectScope, ScopedProjectId.ToString()),
|
||||
new Claim(KnownClaims.CampaignScope, ScopedCampaignId.ToString()),
|
||||
]);
|
||||
|
||||
User dev = await EnsureUserAsync(
|
||||
userManager,
|
||||
id: Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"),
|
||||
username: "dev",
|
||||
email: "dev@socialize.local",
|
||||
password: "Developer1!",
|
||||
alias: "Socialize Dev",
|
||||
firstname: "Jo",
|
||||
lastname: "Bumble",
|
||||
portraitUrl: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=200&q=80",
|
||||
roles: [KnownRoles.Developer, KnownRoles.Administrator, KnownRoles.Manager, KnownRoles.WorkspaceMember],
|
||||
claims:
|
||||
[
|
||||
]);
|
||||
|
||||
await EnsureOrganizationDataAsync(
|
||||
manager.Id,
|
||||
dev.Id,
|
||||
dbContext,
|
||||
cancellationToken);
|
||||
|
||||
await EnsureWorkspaceDataAsync(
|
||||
manager.Id,
|
||||
clientUser.Id,
|
||||
@@ -101,20 +128,7 @@ public static class DevelopmentSeedExtensions
|
||||
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);
|
||||
return services;
|
||||
}
|
||||
|
||||
private static async Task<User> EnsureUserAsync(
|
||||
@@ -151,7 +165,7 @@ public static class DevelopmentSeedExtensions
|
||||
if (!createResult.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to seed development user '{username}': {string.Join(", ", createResult.Errors.Select(error => error.Description))}");
|
||||
$"Failed to seed test user '{username}': {string.Join(", ", createResult.Errors.Select(error => error.Description))}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +185,7 @@ public static class DevelopmentSeedExtensions
|
||||
if (!passwordResetResult.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to set development password for '{username}': {string.Join(", ", passwordResetResult.Errors.Select(error => error.Description))}");
|
||||
$"Failed to set test password for '{username}': {string.Join(", ", passwordResetResult.Errors.Select(error => error.Description))}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +204,7 @@ public static class DevelopmentSeedExtensions
|
||||
|
||||
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)
|
||||
.Where(claim => claim.Type is KnownClaims.WorkspaceScope or KnownClaims.ClientScope or KnownClaims.CampaignScope or KnownClaims.Persona)
|
||||
.ToList();
|
||||
|
||||
foreach (Claim claim in managedClaims)
|
||||
@@ -198,13 +212,7 @@ public static class DevelopmentSeedExtensions
|
||||
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;
|
||||
string persona = GetPersona(roles);
|
||||
|
||||
foreach (Claim claim in claims.Concat([new Claim(KnownClaims.Persona, persona)]))
|
||||
{
|
||||
@@ -214,6 +222,100 @@ public static class DevelopmentSeedExtensions
|
||||
return user;
|
||||
}
|
||||
|
||||
private static string GetPersona(IReadOnlyCollection<string> roles)
|
||||
{
|
||||
if (roles.Contains(KnownRoles.Manager, StringComparer.Ordinal))
|
||||
{
|
||||
return KnownRoles.Manager;
|
||||
}
|
||||
|
||||
if (roles.Contains(KnownRoles.Client, StringComparer.Ordinal))
|
||||
{
|
||||
return KnownRoles.Client;
|
||||
}
|
||||
|
||||
if (roles.Contains(KnownRoles.Provider, StringComparer.Ordinal))
|
||||
{
|
||||
return KnownRoles.Provider;
|
||||
}
|
||||
|
||||
return KnownRoles.WorkspaceMember;
|
||||
}
|
||||
|
||||
private static async Task EnsureOrganizationDataAsync(
|
||||
Guid managerUserId,
|
||||
Guid developerUserId,
|
||||
AppDbContext dbContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Organization? organization = await dbContext.Organizations
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == OrganizationId, cancellationToken);
|
||||
if (organization is null)
|
||||
{
|
||||
organization = new Organization
|
||||
{
|
||||
Id = OrganizationId,
|
||||
Name = string.Empty,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
dbContext.Organizations.Add(organization);
|
||||
}
|
||||
|
||||
organization.Name = "Northstar Agency";
|
||||
organization.IsGoogleDriveDamEnabled = true;
|
||||
organization.GoogleDriveRootFolderId = "dev-socialize-dam-root";
|
||||
organization.GoogleDriveRootFolderName = "Socialize DAM";
|
||||
organization.GoogleDriveRootFolderUrl = "https://drive.google.com/drive/folders/dev-socialize-dam-root";
|
||||
organization.MembershipTierId = OrganizationMembershipTierSeed.AgencyId;
|
||||
organization.OwnerUserId = managerUserId;
|
||||
|
||||
await UpsertOrganizationMembershipAsync(
|
||||
dbContext,
|
||||
Guid.Parse("99999999-9999-9999-9999-000000000001"),
|
||||
OrganizationId,
|
||||
managerUserId,
|
||||
OrganizationRoles.Owner,
|
||||
cancellationToken);
|
||||
|
||||
await UpsertOrganizationMembershipAsync(
|
||||
dbContext,
|
||||
Guid.Parse("99999999-9999-9999-9999-000000000002"),
|
||||
OrganizationId,
|
||||
developerUserId,
|
||||
OrganizationRoles.Admin,
|
||||
cancellationToken);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task UpsertOrganizationMembershipAsync(
|
||||
AppDbContext dbContext,
|
||||
Guid membershipId,
|
||||
Guid organizationId,
|
||||
Guid userId,
|
||||
string role,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
OrganizationMembership? membership = await dbContext.OrganizationMemberships
|
||||
.SingleOrDefaultAsync(
|
||||
candidate => candidate.OrganizationId == organizationId && candidate.UserId == userId,
|
||||
cancellationToken);
|
||||
if (membership is null)
|
||||
{
|
||||
membership = new OrganizationMembership
|
||||
{
|
||||
Id = membershipId,
|
||||
OrganizationId = organizationId,
|
||||
UserId = userId,
|
||||
Role = role,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
dbContext.OrganizationMemberships.Add(membership);
|
||||
}
|
||||
|
||||
membership.Role = role;
|
||||
}
|
||||
|
||||
private static async Task EnsureWorkspaceDataAsync(
|
||||
Guid managerUserId,
|
||||
Guid clientUserId,
|
||||
@@ -221,33 +323,31 @@ public static class DevelopmentSeedExtensions
|
||||
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 UpsertWorkspaceAsync(
|
||||
dbContext,
|
||||
WorkspaceId,
|
||||
OrganizationId,
|
||||
managerUserId,
|
||||
"Luma Coffee",
|
||||
"America/Montreal",
|
||||
"/images/seed/luma-coffee-logo.svg",
|
||||
cancellationToken);
|
||||
await UpsertWorkspaceAsync(
|
||||
dbContext,
|
||||
AtlasWorkspaceId,
|
||||
OrganizationId,
|
||||
managerUserId,
|
||||
"Atlas Bakery",
|
||||
"America/Montreal",
|
||||
"/images/seed/atlas-bakery-logo.svg",
|
||||
cancellationToken);
|
||||
|
||||
await UpsertClientAsync(
|
||||
dbContext,
|
||||
ScopedClientId,
|
||||
"Luma Coffee",
|
||||
"Active",
|
||||
"https://images.unsplash.com/photo-1511920170033-f8396924c348?auto=format&fit=crop&w=200&q=80",
|
||||
"/images/seed/luma-coffee-logo.svg",
|
||||
"Sofia Martin",
|
||||
"client@socialize.local",
|
||||
WorkspaceId,
|
||||
@@ -257,15 +357,15 @@ public static class DevelopmentSeedExtensions
|
||||
HiddenClientId,
|
||||
"Atlas Bakery",
|
||||
"Active",
|
||||
"https://images.unsplash.com/photo-1509440159596-0249088772ff?auto=format&fit=crop&w=200&q=80",
|
||||
"/images/seed/atlas-bakery-logo.svg",
|
||||
"Nina Cole",
|
||||
"nina@atlasbakery.test",
|
||||
WorkspaceId,
|
||||
AtlasWorkspaceId,
|
||||
cancellationToken);
|
||||
|
||||
await UpsertProjectAsync(
|
||||
await UpsertCampaignAsync(
|
||||
dbContext,
|
||||
ScopedProjectId,
|
||||
ScopedCampaignId,
|
||||
WorkspaceId,
|
||||
ScopedClientId,
|
||||
"Spring Launch",
|
||||
@@ -275,10 +375,10 @@ public static class DevelopmentSeedExtensions
|
||||
"Cross-channel launch campaign for the spring offer.",
|
||||
"Coordinate creative approvals before the final week.",
|
||||
cancellationToken);
|
||||
await UpsertProjectAsync(
|
||||
await UpsertCampaignAsync(
|
||||
dbContext,
|
||||
HiddenProjectId,
|
||||
WorkspaceId,
|
||||
HiddenCampaignId,
|
||||
AtlasWorkspaceId,
|
||||
HiddenClientId,
|
||||
"Summer Retention",
|
||||
"Planned",
|
||||
@@ -288,16 +388,44 @@ public static class DevelopmentSeedExtensions
|
||||
"Sequence email and paid social updates together.",
|
||||
cancellationToken);
|
||||
|
||||
await UpsertChannelAsync(
|
||||
dbContext,
|
||||
LumaInstagramChannelId,
|
||||
WorkspaceId,
|
||||
"Luma Coffee Instagram",
|
||||
"Instagram",
|
||||
"@lumacoffee",
|
||||
null,
|
||||
cancellationToken);
|
||||
await UpsertChannelAsync(
|
||||
dbContext,
|
||||
LumaTikTokChannelId,
|
||||
WorkspaceId,
|
||||
"Luma Coffee TikTok",
|
||||
"TikTok",
|
||||
"@lumacoffee",
|
||||
null,
|
||||
cancellationToken);
|
||||
await UpsertChannelAsync(
|
||||
dbContext,
|
||||
AtlasInstagramChannelId,
|
||||
AtlasWorkspaceId,
|
||||
"Atlas Bakery Instagram",
|
||||
"Instagram",
|
||||
"@atlasbakery",
|
||||
null,
|
||||
cancellationToken);
|
||||
|
||||
await UpsertContentItemAsync(
|
||||
dbContext,
|
||||
ScopedContentItemId,
|
||||
WorkspaceId,
|
||||
ScopedClientId,
|
||||
ScopedProjectId,
|
||||
ScopedCampaignId,
|
||||
"Spring launch hero video",
|
||||
"Fresh seasonal menu launch across Instagram and TikTok.",
|
||||
"Instagram Reel, TikTok",
|
||||
"In client review",
|
||||
"Luma Coffee Instagram, Luma Coffee TikTok",
|
||||
"In approval",
|
||||
DateTimeOffset.UtcNow.AddDays(3),
|
||||
"v3",
|
||||
3,
|
||||
@@ -305,22 +433,22 @@ public static class DevelopmentSeedExtensions
|
||||
await UpsertContentItemAsync(
|
||||
dbContext,
|
||||
HiddenContentItemId,
|
||||
WorkspaceId,
|
||||
AtlasWorkspaceId,
|
||||
HiddenClientId,
|
||||
HiddenProjectId,
|
||||
HiddenCampaignId,
|
||||
"Bakery loyalty carousel",
|
||||
"Reward regular customers with a four-card retention carousel.",
|
||||
"Instagram Carousel",
|
||||
"Atlas Bakery Instagram",
|
||||
"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);
|
||||
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000001"), ScopedContentItemId, 1, "v1", "Spring launch hero video", "Initial draft for the seasonal menu launch.", "Luma Coffee Instagram, Luma Coffee 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.", "Luma Coffee Instagram, Luma Coffee 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.", "Luma Coffee Instagram, Luma Coffee 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.", "Atlas Bakery Instagram", "First draft.", managerUserId, DateTimeOffset.UtcNow.AddDays(-2), cancellationToken);
|
||||
|
||||
Asset? asset = await dbContext.Assets.SingleOrDefaultAsync(candidate => candidate.Id == ScopedAssetId, cancellationToken);
|
||||
if (asset is null)
|
||||
@@ -342,6 +470,7 @@ public static class DevelopmentSeedExtensions
|
||||
asset.DisplayName = "Spring launch cut";
|
||||
asset.GoogleDriveFileId = "dev-socialize-demo";
|
||||
asset.GoogleDriveLink = "https://drive.google.com/file/d/dev-socialize-demo/view";
|
||||
asset.GoogleDriveWorkspaceFolderPath = "Socialize DAM/luma-coffee";
|
||||
asset.PreviewUrl = "https://drive.google.com/thumbnail?id=dev-socialize-demo";
|
||||
asset.CurrentRevisionNumber = 2;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
@@ -368,8 +497,6 @@ public static class DevelopmentSeedExtensions
|
||||
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);
|
||||
@@ -448,6 +575,45 @@ public static class DevelopmentSeedExtensions
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task UpsertWorkspaceAsync(
|
||||
AppDbContext dbContext,
|
||||
Guid id,
|
||||
Guid organizationId,
|
||||
Guid ownerUserId,
|
||||
string name,
|
||||
string timeZone,
|
||||
string logoUrl,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Workspace? workspace = await dbContext.Workspaces
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
|
||||
if (workspace is null)
|
||||
{
|
||||
workspace = new Workspace
|
||||
{
|
||||
Id = id,
|
||||
Name = string.Empty,
|
||||
Slug = string.Empty,
|
||||
TimeZone = string.Empty,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
dbContext.Workspaces.Add(workspace);
|
||||
}
|
||||
|
||||
workspace.Name = name;
|
||||
workspace.Slug = await WorkspaceSlugGenerator.CreateUniqueAsync(
|
||||
dbContext,
|
||||
organizationId,
|
||||
string.IsNullOrWhiteSpace(workspace.Slug) ? name : workspace.Slug,
|
||||
workspace.Id,
|
||||
cancellationToken);
|
||||
workspace.OrganizationId = organizationId;
|
||||
workspace.OwnerUserId = ownerUserId;
|
||||
workspace.TimeZone = timeZone;
|
||||
workspace.LogoUrl = logoUrl;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task UpsertClientAsync(
|
||||
AppDbContext dbContext,
|
||||
Guid id,
|
||||
@@ -481,7 +647,7 @@ public static class DevelopmentSeedExtensions
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task UpsertProjectAsync(
|
||||
private static async Task UpsertCampaignAsync(
|
||||
AppDbContext dbContext,
|
||||
Guid id,
|
||||
Guid workspaceId,
|
||||
@@ -494,26 +660,57 @@ public static class DevelopmentSeedExtensions
|
||||
string? notes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Project? project = await dbContext.Projects.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
|
||||
if (project is null)
|
||||
Campaign? campaign = await dbContext.Campaigns.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
|
||||
if (campaign is null)
|
||||
{
|
||||
project = new Project
|
||||
campaign = new Campaign
|
||||
{
|
||||
Id = id,
|
||||
Name = string.Empty,
|
||||
Status = string.Empty,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
dbContext.Projects.Add(project);
|
||||
dbContext.Campaigns.Add(campaign);
|
||||
}
|
||||
project.WorkspaceId = workspaceId;
|
||||
project.ClientId = clientId;
|
||||
project.Name = name;
|
||||
project.Description = description;
|
||||
project.Notes = notes;
|
||||
project.Status = status;
|
||||
project.StartDate = startDate;
|
||||
project.EndDate = endDate;
|
||||
campaign.WorkspaceId = workspaceId;
|
||||
campaign.ClientId = clientId;
|
||||
campaign.Name = name;
|
||||
campaign.Description = description;
|
||||
campaign.Notes = notes;
|
||||
campaign.Status = status;
|
||||
campaign.StartDate = startDate;
|
||||
campaign.EndDate = endDate;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task UpsertChannelAsync(
|
||||
AppDbContext dbContext,
|
||||
Guid id,
|
||||
Guid workspaceId,
|
||||
string name,
|
||||
string network,
|
||||
string? handle,
|
||||
string? externalUrl,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Channel? channel = await dbContext.Channels.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
|
||||
if (channel is null)
|
||||
{
|
||||
channel = new Channel
|
||||
{
|
||||
Id = id,
|
||||
Name = string.Empty,
|
||||
Network = string.Empty,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
dbContext.Channels.Add(channel);
|
||||
}
|
||||
|
||||
channel.WorkspaceId = workspaceId;
|
||||
channel.Name = name;
|
||||
channel.Network = network;
|
||||
channel.Handle = handle;
|
||||
channel.ExternalUrl = externalUrl;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -522,7 +719,7 @@ public static class DevelopmentSeedExtensions
|
||||
Guid id,
|
||||
Guid workspaceId,
|
||||
Guid clientId,
|
||||
Guid projectId,
|
||||
Guid campaignId,
|
||||
string title,
|
||||
string publicationMessage,
|
||||
string publicationTargets,
|
||||
@@ -549,7 +746,7 @@ public static class DevelopmentSeedExtensions
|
||||
}
|
||||
item.WorkspaceId = workspaceId;
|
||||
item.ClientId = clientId;
|
||||
item.ProjectId = projectId;
|
||||
item.CampaignId = campaignId;
|
||||
item.Title = title;
|
||||
item.PublicationMessage = publicationMessage;
|
||||
item.PublicationTargets = publicationTargets;
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Socialize.Infrastructure.YouTube;
|
||||
namespace Socialize.Api.Infrastructure.YouTube;
|
||||
|
||||
public static class YouTubeUrlHelper
|
||||
internal static class YouTubeUrlHelper
|
||||
{
|
||||
private static readonly Regex VideoIdRegex = new(
|
||||
@"(?:youtube\.com/(?:[^/]+/.+/|(?:v|e(?:mbed)?)/|.*[?&]v=)|youtu\.be/)([^""&?/\s]{11})",
|
||||
1946
backend/src/Socialize.Api/Migrations/20260507143849_Initial.Designer.cs
generated
Normal file
1946
backend/src/Socialize.Api/Migrations/20260507143849_Initial.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
1284
backend/src/Socialize.Api/Migrations/20260507143849_Initial.cs
Normal file
1284
backend/src/Socialize.Api/Migrations/20260507143849_Initial.cs
Normal file
File diff suppressed because it is too large
Load Diff
2173
backend/src/Socialize.Api/Migrations/20260507185052_AddMissingDomainForeignKeys.Designer.cs
generated
Normal file
2173
backend/src/Socialize.Api/Migrations/20260507185052_AddMissingDomainForeignKeys.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,405 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Socialize.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
internal partial class AddMissingDomainForeignKeys : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FeedbackReports_CampaignId",
|
||||
table: "FeedbackReports",
|
||||
column: "CampaignId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FeedbackReports_ClientId",
|
||||
table: "FeedbackReports",
|
||||
column: "ClientId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FeedbackReports_ContentItemId",
|
||||
table: "FeedbackReports",
|
||||
column: "ContentItemId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ApprovalDecisions_ApprovalRequests_ApprovalRequestId",
|
||||
table: "ApprovalDecisions",
|
||||
column: "ApprovalRequestId",
|
||||
principalTable: "ApprovalRequests",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ApprovalRequests_ApprovalWorkflowInstances_WorkflowInstance~",
|
||||
table: "ApprovalRequests",
|
||||
column: "WorkflowInstanceId",
|
||||
principalTable: "ApprovalWorkflowInstances",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ApprovalRequests_ContentItems_ContentItemId",
|
||||
table: "ApprovalRequests",
|
||||
column: "ContentItemId",
|
||||
principalTable: "ContentItems",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ApprovalRequests_Workspaces_WorkspaceId",
|
||||
table: "ApprovalRequests",
|
||||
column: "WorkspaceId",
|
||||
principalTable: "Workspaces",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ApprovalWorkflowInstances_ContentItems_ContentItemId",
|
||||
table: "ApprovalWorkflowInstances",
|
||||
column: "ContentItemId",
|
||||
principalTable: "ContentItems",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ApprovalWorkflowInstances_Workspaces_WorkspaceId",
|
||||
table: "ApprovalWorkflowInstances",
|
||||
column: "WorkspaceId",
|
||||
principalTable: "Workspaces",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_AssetRevisions_Assets_AssetId",
|
||||
table: "AssetRevisions",
|
||||
column: "AssetId",
|
||||
principalTable: "Assets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Assets_ContentItems_ContentItemId",
|
||||
table: "Assets",
|
||||
column: "ContentItemId",
|
||||
principalTable: "ContentItems",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Assets_Workspaces_WorkspaceId",
|
||||
table: "Assets",
|
||||
column: "WorkspaceId",
|
||||
principalTable: "Workspaces",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Campaigns_Clients_ClientId",
|
||||
table: "Campaigns",
|
||||
column: "ClientId",
|
||||
principalTable: "Clients",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Campaigns_Workspaces_WorkspaceId",
|
||||
table: "Campaigns",
|
||||
column: "WorkspaceId",
|
||||
principalTable: "Workspaces",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Channels_Workspaces_WorkspaceId",
|
||||
table: "Channels",
|
||||
column: "WorkspaceId",
|
||||
principalTable: "Workspaces",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Clients_Workspaces_WorkspaceId",
|
||||
table: "Clients",
|
||||
column: "WorkspaceId",
|
||||
principalTable: "Workspaces",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Comments_Comments_ParentCommentId",
|
||||
table: "Comments",
|
||||
column: "ParentCommentId",
|
||||
principalTable: "Comments",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Comments_ContentItems_ContentItemId",
|
||||
table: "Comments",
|
||||
column: "ContentItemId",
|
||||
principalTable: "ContentItems",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Comments_Workspaces_WorkspaceId",
|
||||
table: "Comments",
|
||||
column: "WorkspaceId",
|
||||
principalTable: "Workspaces",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ContentItemActivityEntries_ContentItems_ContentItemId",
|
||||
table: "ContentItemActivityEntries",
|
||||
column: "ContentItemId",
|
||||
principalTable: "ContentItems",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ContentItemActivityEntries_Workspaces_WorkspaceId",
|
||||
table: "ContentItemActivityEntries",
|
||||
column: "WorkspaceId",
|
||||
principalTable: "Workspaces",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ContentItemRevisions_ContentItems_ContentItemId",
|
||||
table: "ContentItemRevisions",
|
||||
column: "ContentItemId",
|
||||
principalTable: "ContentItems",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ContentItems_Campaigns_CampaignId",
|
||||
table: "ContentItems",
|
||||
column: "CampaignId",
|
||||
principalTable: "Campaigns",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ContentItems_Clients_ClientId",
|
||||
table: "ContentItems",
|
||||
column: "ClientId",
|
||||
principalTable: "Clients",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ContentItems_Workspaces_WorkspaceId",
|
||||
table: "ContentItems",
|
||||
column: "WorkspaceId",
|
||||
principalTable: "Workspaces",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_FeedbackReports_Campaigns_CampaignId",
|
||||
table: "FeedbackReports",
|
||||
column: "CampaignId",
|
||||
principalTable: "Campaigns",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_FeedbackReports_Clients_ClientId",
|
||||
table: "FeedbackReports",
|
||||
column: "ClientId",
|
||||
principalTable: "Clients",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_FeedbackReports_ContentItems_ContentItemId",
|
||||
table: "FeedbackReports",
|
||||
column: "ContentItemId",
|
||||
principalTable: "ContentItems",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_FeedbackReports_Workspaces_WorkspaceId",
|
||||
table: "FeedbackReports",
|
||||
column: "WorkspaceId",
|
||||
principalTable: "Workspaces",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_NotificationEvents_ContentItems_ContentItemId",
|
||||
table: "NotificationEvents",
|
||||
column: "ContentItemId",
|
||||
principalTable: "ContentItems",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_NotificationEvents_Workspaces_WorkspaceId",
|
||||
table: "NotificationEvents",
|
||||
column: "WorkspaceId",
|
||||
principalTable: "Workspaces",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_WorkspaceApprovalStepConfigurations_Workspaces_WorkspaceId",
|
||||
table: "WorkspaceApprovalStepConfigurations",
|
||||
column: "WorkspaceId",
|
||||
principalTable: "Workspaces",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_WorkspaceInvites_Workspaces_WorkspaceId",
|
||||
table: "WorkspaceInvites",
|
||||
column: "WorkspaceId",
|
||||
principalTable: "Workspaces",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ApprovalDecisions_ApprovalRequests_ApprovalRequestId",
|
||||
table: "ApprovalDecisions");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ApprovalRequests_ApprovalWorkflowInstances_WorkflowInstance~",
|
||||
table: "ApprovalRequests");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ApprovalRequests_ContentItems_ContentItemId",
|
||||
table: "ApprovalRequests");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ApprovalRequests_Workspaces_WorkspaceId",
|
||||
table: "ApprovalRequests");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ApprovalWorkflowInstances_ContentItems_ContentItemId",
|
||||
table: "ApprovalWorkflowInstances");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ApprovalWorkflowInstances_Workspaces_WorkspaceId",
|
||||
table: "ApprovalWorkflowInstances");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_AssetRevisions_Assets_AssetId",
|
||||
table: "AssetRevisions");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Assets_ContentItems_ContentItemId",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Assets_Workspaces_WorkspaceId",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Campaigns_Clients_ClientId",
|
||||
table: "Campaigns");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Campaigns_Workspaces_WorkspaceId",
|
||||
table: "Campaigns");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Channels_Workspaces_WorkspaceId",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Clients_Workspaces_WorkspaceId",
|
||||
table: "Clients");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Comments_Comments_ParentCommentId",
|
||||
table: "Comments");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Comments_ContentItems_ContentItemId",
|
||||
table: "Comments");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Comments_Workspaces_WorkspaceId",
|
||||
table: "Comments");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ContentItemActivityEntries_ContentItems_ContentItemId",
|
||||
table: "ContentItemActivityEntries");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ContentItemActivityEntries_Workspaces_WorkspaceId",
|
||||
table: "ContentItemActivityEntries");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ContentItemRevisions_ContentItems_ContentItemId",
|
||||
table: "ContentItemRevisions");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ContentItems_Campaigns_CampaignId",
|
||||
table: "ContentItems");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ContentItems_Clients_ClientId",
|
||||
table: "ContentItems");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ContentItems_Workspaces_WorkspaceId",
|
||||
table: "ContentItems");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_FeedbackReports_Campaigns_CampaignId",
|
||||
table: "FeedbackReports");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_FeedbackReports_Clients_ClientId",
|
||||
table: "FeedbackReports");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_FeedbackReports_ContentItems_ContentItemId",
|
||||
table: "FeedbackReports");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_FeedbackReports_Workspaces_WorkspaceId",
|
||||
table: "FeedbackReports");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_NotificationEvents_ContentItems_ContentItemId",
|
||||
table: "NotificationEvents");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_NotificationEvents_Workspaces_WorkspaceId",
|
||||
table: "NotificationEvents");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_WorkspaceApprovalStepConfigurations_Workspaces_WorkspaceId",
|
||||
table: "WorkspaceApprovalStepConfigurations");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_WorkspaceInvites_Workspaces_WorkspaceId",
|
||||
table: "WorkspaceInvites");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_FeedbackReports_CampaignId",
|
||||
table: "FeedbackReports");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_FeedbackReports_ClientId",
|
||||
table: "FeedbackReports");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_FeedbackReports_ContentItemId",
|
||||
table: "FeedbackReports");
|
||||
}
|
||||
}
|
||||
}
|
||||
2293
backend/src/Socialize.Api/Migrations/20260508001846_AddOrganizationMembershipTiers.Designer.cs
generated
Normal file
2293
backend/src/Socialize.Api/Migrations/20260508001846_AddOrganizationMembershipTiers.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace Socialize.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
internal partial class AddOrganizationMembershipTiers : Migration
|
||||
{
|
||||
private static readonly string[] MembershipTierSeedColumns =
|
||||
[
|
||||
"Id",
|
||||
"ActiveContentLimit",
|
||||
"Description",
|
||||
"ExternalReviewerLimit",
|
||||
"IsCustom",
|
||||
"Key",
|
||||
"MemberLimit",
|
||||
"MonthlyPriceCents",
|
||||
"Name",
|
||||
"SortOrder",
|
||||
"WorkspaceLimit"
|
||||
];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(migrationBuilder);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "MembershipTierId",
|
||||
table: "Organizations",
|
||||
type: "uuid",
|
||||
nullable: false,
|
||||
defaultValue: new Guid("20000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "OrganizationMembershipTiers",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Key = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
||||
MonthlyPriceCents = table.Column<int>(type: "integer", nullable: true),
|
||||
WorkspaceLimit = table.Column<int>(type: "integer", nullable: true),
|
||||
ActiveContentLimit = table.Column<int>(type: "integer", nullable: true),
|
||||
MemberLimit = table.Column<int>(type: "integer", nullable: true),
|
||||
ExternalReviewerLimit = table.Column<int>(type: "integer", nullable: true),
|
||||
IsCustom = table.Column<bool>(type: "boolean", nullable: false),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_OrganizationMembershipTiers", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "OrganizationMembershipTiers",
|
||||
columns: MembershipTierSeedColumns,
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("20000000-0000-0000-0000-000000000001"), 3, "For trying Socialize on one real approval workflow.", 1, false, "free", 2, 0, "Free", 10, 1 },
|
||||
{ new Guid("20000000-0000-0000-0000-000000000002"), 25, "For solo operators managing recurring client reviews.", 10, false, "freelance", 5, 1900, "Freelance", 20, 3 },
|
||||
{ new Guid("20000000-0000-0000-0000-000000000003"), 250, "For agencies that need repeatable client approval operations.", null, false, "agency", 25, 7900, "Agency", 30, 15 },
|
||||
{ new Guid("20000000-0000-0000-0000-000000000004"), null, "For larger organizations with governance and access needs.", null, true, "enterprise", null, null, "Enterprise", 40, null }
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Organizations_MembershipTierId",
|
||||
table: "Organizations",
|
||||
column: "MembershipTierId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OrganizationMembershipTiers_Key",
|
||||
table: "OrganizationMembershipTiers",
|
||||
column: "Key",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OrganizationMembershipTiers_SortOrder",
|
||||
table: "OrganizationMembershipTiers",
|
||||
column: "SortOrder");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Organizations_OrganizationMembershipTiers_MembershipTierId",
|
||||
table: "Organizations",
|
||||
column: "MembershipTierId",
|
||||
principalTable: "OrganizationMembershipTiers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(migrationBuilder);
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Organizations_OrganizationMembershipTiers_MembershipTierId",
|
||||
table: "Organizations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "OrganizationMembershipTiers");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Organizations_MembershipTierId",
|
||||
table: "Organizations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MembershipTierId",
|
||||
table: "Organizations");
|
||||
}
|
||||
}
|
||||
}
|
||||
2382
backend/src/Socialize.Api/Migrations/20260508003746_LocalizeOrganizationMembershipTiers.Designer.cs
generated
Normal file
2382
backend/src/Socialize.Api/Migrations/20260508003746_LocalizeOrganizationMembershipTiers.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace Socialize.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
internal partial class LocalizeOrganizationMembershipTiers : Migration
|
||||
{
|
||||
private static readonly string[] MembershipTierTranslationSeedColumns =
|
||||
[
|
||||
"Id",
|
||||
"Culture",
|
||||
"Description",
|
||||
"MembershipTierId",
|
||||
"Name"
|
||||
];
|
||||
|
||||
private static readonly string[] MembershipTierColumnsToRestore =
|
||||
[
|
||||
"Description",
|
||||
"Name"
|
||||
];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(migrationBuilder);
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Description",
|
||||
table: "OrganizationMembershipTiers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Name",
|
||||
table: "OrganizationMembershipTiers");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "OrganizationMembershipTierTranslations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
MembershipTierId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Culture = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_OrganizationMembershipTierTranslations", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_OrganizationMembershipTierTranslations_OrganizationMembersh~",
|
||||
column: x => x.MembershipTierId,
|
||||
principalTable: "OrganizationMembershipTiers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "OrganizationMembershipTierTranslations",
|
||||
columns: MembershipTierTranslationSeedColumns,
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("20000000-0000-0001-0000-000000000001"), "en", "For trying Socialize on one real approval workflow.", new Guid("20000000-0000-0000-0000-000000000001"), "Free" },
|
||||
{ new Guid("20000000-0000-0001-0000-000000000002"), "fr", "Pour essayer Socialize sur un vrai workflow d'approbation.", new Guid("20000000-0000-0000-0000-000000000001"), "Free" },
|
||||
{ new Guid("20000000-0000-0001-0000-000000000003"), "en", "For solo operators managing recurring client reviews.", new Guid("20000000-0000-0000-0000-000000000002"), "Freelance" },
|
||||
{ new Guid("20000000-0000-0001-0000-000000000004"), "fr", "Pour les independants qui gerent des revisions client recurrentes.", new Guid("20000000-0000-0000-0000-000000000002"), "Freelance" },
|
||||
{ new Guid("20000000-0000-0001-0000-000000000005"), "en", "For agencies that need repeatable client approval operations.", new Guid("20000000-0000-0000-0000-000000000003"), "Agency" },
|
||||
{ new Guid("20000000-0000-0001-0000-000000000006"), "fr", "Pour les agences qui veulent des operations d'approbation client repetables.", new Guid("20000000-0000-0000-0000-000000000003"), "Agency" },
|
||||
{ new Guid("20000000-0000-0001-0000-000000000007"), "en", "For larger organizations with governance and access needs.", new Guid("20000000-0000-0000-0000-000000000004"), "Enterprise" },
|
||||
{ new Guid("20000000-0000-0001-0000-000000000008"), "fr", "Pour les grandes organisations avec des besoins de gouvernance et d'acces.", new Guid("20000000-0000-0000-0000-000000000004"), "Enterprise" }
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OrganizationMembershipTierTranslations_MembershipTierId_Cul~",
|
||||
table: "OrganizationMembershipTierTranslations",
|
||||
columns: ["MembershipTierId", "Culture"],
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(migrationBuilder);
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "OrganizationMembershipTierTranslations");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Description",
|
||||
table: "OrganizationMembershipTiers",
|
||||
type: "character varying(512)",
|
||||
maxLength: 512,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Name",
|
||||
table: "OrganizationMembershipTiers",
|
||||
type: "character varying(128)",
|
||||
maxLength: 128,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "OrganizationMembershipTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("20000000-0000-0000-0000-000000000001"),
|
||||
columns: MembershipTierColumnsToRestore,
|
||||
values: new object[] { "For trying Socialize on one real approval workflow.", "Free" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "OrganizationMembershipTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("20000000-0000-0000-0000-000000000002"),
|
||||
columns: MembershipTierColumnsToRestore,
|
||||
values: new object[] { "For solo operators managing recurring client reviews.", "Freelance" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "OrganizationMembershipTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("20000000-0000-0000-0000-000000000003"),
|
||||
columns: MembershipTierColumnsToRestore,
|
||||
values: new object[] { "For agencies that need repeatable client approval operations.", "Agency" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "OrganizationMembershipTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("20000000-0000-0000-0000-000000000004"),
|
||||
columns: MembershipTierColumnsToRestore,
|
||||
values: new object[] { "For larger organizations with governance and access needs.", "Enterprise" });
|
||||
}
|
||||
}
|
||||
}
|
||||
2628
backend/src/Socialize.Api/Migrations/20260508010206_AddReleaseCommunications.Designer.cs
generated
Normal file
2628
backend/src/Socialize.Api/Migrations/20260508010206_AddReleaseCommunications.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user