Compare commits
67 Commits
df3e602015
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
56
.gitea/workflows/deploy-socialize.yml
Normal file
56
.gitea/workflows/deploy-socialize.yml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
name: deploy-socialize
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Check repository hygiene
|
||||||
|
run: ./scripts/check-repo-hygiene.sh
|
||||||
|
- name: Install Docker CLI
|
||||||
|
run: apt-get update && apt-get install -y docker.io
|
||||||
|
- name: Login to Gitea container registry
|
||||||
|
env:
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
run: printf '%s' "$REGISTRY_PASSWORD" | docker login git.mapachotes.com -u "$REGISTRY_USER" --password-stdin
|
||||||
|
- name: Build images
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
-t git.mapachotes.com/jbourdon/socialize-api:${{ gitea.sha }} \
|
||||||
|
-t git.mapachotes.com/jbourdon/socialize-api:latest \
|
||||||
|
-f backend/src/Socialize.Api/Dockerfile .
|
||||||
|
docker build \
|
||||||
|
--build-arg VITE_API_URL=/ \
|
||||||
|
-t git.mapachotes.com/jbourdon/socialize-web:${{ gitea.sha }} \
|
||||||
|
-t git.mapachotes.com/jbourdon/socialize-web:latest \
|
||||||
|
-f frontend/Dockerfile .
|
||||||
|
- name: Push images
|
||||||
|
run: |
|
||||||
|
docker push git.mapachotes.com/jbourdon/socialize-api:${{ gitea.sha }}
|
||||||
|
docker push git.mapachotes.com/jbourdon/socialize-api:latest
|
||||||
|
docker push git.mapachotes.com/jbourdon/socialize-web:${{ gitea.sha }}
|
||||||
|
docker push git.mapachotes.com/jbourdon/socialize-web:latest
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: image
|
||||||
|
runs-on: bookworm
|
||||||
|
steps:
|
||||||
|
- name: Install SSH client
|
||||||
|
run: apt-get update && apt-get install -y openssh-client
|
||||||
|
- name: Deploy on sobina
|
||||||
|
env:
|
||||||
|
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||||
|
DEPLOY_SSH_PRIVATE_KEY_B64: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY_B64 }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s' "$DEPLOY_SSH_PRIVATE_KEY_B64" | base64 -d > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||||
|
'cd /srv/prod/socialize && ./deploy.sh'
|
||||||
39
.github/workflows/backend-ci.yml
vendored
39
.github/workflows/backend-ci.yml
vendored
@@ -1,39 +0,0 @@
|
|||||||
name: Backend CI/CD
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
env:
|
|
||||||
AZURE_WEBAPP_NAME: hutopy-backend-api
|
|
||||||
DOTNET_VERSION: '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 }}
|
|
||||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -19,10 +19,25 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# .NET
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
**/[Bb]in/
|
||||||
|
**/[Oo]bj/
|
||||||
|
**/[Bb]in[\\]*
|
||||||
|
**/[Oo]bj[\\]*
|
||||||
|
TestResults/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
|
||||||
# Local environment files
|
# Local environment files
|
||||||
*.local
|
*.local
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
App_Data/
|
||||||
|
|
||||||
# Local SSL certificates
|
# Local SSL certificates
|
||||||
*.pem
|
*.pem
|
||||||
|
|||||||
239
AGENTS.md
239
AGENTS.md
@@ -1,104 +1,76 @@
|
|||||||
# AGENTS.md
|
# AGENTS
|
||||||
|
|
||||||
## Purpose
|
This repository is designed for human + AI agent collaboration.
|
||||||
This document is a working guide for coding agents in this repository. It captures the current architecture, conventions, and safe execution workflow for making reliable changes.
|
|
||||||
|
|
||||||
## Documentation-First Workflow
|
## Read Order
|
||||||
Agents must treat repository documentation as the source of truth. Conversation history is secondary and may be incomplete, stale, or contradictory.
|
|
||||||
|
|
||||||
Before making any substantial code change, agents must read the relevant docs first. At minimum, inspect:
|
Before meaningful code changes, read:
|
||||||
- `AGENTS.md`
|
|
||||||
- `docs/LLM_DEVELOPMENT_WORKFLOW.md`
|
|
||||||
- `docs/PRODUCT.md` when product behavior, UX, or user workflow may change
|
|
||||||
- `docs/ARCHITECTURE.md` when structure, module boundaries, routing, data flow, or integration points may change
|
|
||||||
- `docs/CONVENTIONS.md` when adding or modifying code patterns
|
|
||||||
- `docs/DECISIONS.md` before revisiting architecture or product decisions
|
|
||||||
- the active `docs/tasks/TASK-*.md` file when one exists
|
|
||||||
|
|
||||||
If one of these files does not exist yet, do not invent broad behavior from chat history. State what is missing and proceed only with the narrowest safe interpretation of the current task.
|
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:
|
## Core Rules
|
||||||
1. Read the relevant docs and existing code.
|
|
||||||
2. Restate the task in a short summary.
|
|
||||||
3. Identify backend impact, frontend impact, data impact, and documentation impact.
|
|
||||||
4. List files likely to change.
|
|
||||||
5. Surface ambiguities or risky assumptions.
|
|
||||||
6. Propose a minimal implementation plan.
|
|
||||||
7. Implement only the approved or clearly requested scope.
|
|
||||||
8. Validate with the relevant commands before finishing when possible.
|
|
||||||
|
|
||||||
Do not use a long chat thread as the durable memory for the project. Durable decisions, conventions, and task requirements belong in repository docs.
|
- Do not invent architecture.
|
||||||
|
- Work from docs, feature specs, and task files instead of long chat history.
|
||||||
## Pair Working Mode
|
- Keep backend code under `backend/src/Socialize.Api`.
|
||||||
- Work as a pair with the repository owner, not as an isolated implementer.
|
- The solution file is `backend/Socialize.slnx`.
|
||||||
- Before substantial changes, read the relevant docs first, then restate the task briefly and inspect the existing code.
|
- Backend feature code currently follows FastEndpoints module folders under `Modules/<Feature>`.
|
||||||
- Surface assumptions, tradeoffs, and blockers early instead of silently picking risky directions.
|
- Frontend feature-owned code belongs under `frontend/src/features/<feature>`.
|
||||||
- Prefer small, reviewable increments when the product direction is still being shaped.
|
- Frontend runtime config must flow through `frontend/src/config.js`.
|
||||||
- When requirements are exploratory, help turn them into concrete workflows, domain language, and next implementation steps.
|
- If backend contracts change, run `./scripts/update-openapi.sh` when the backend is running.
|
||||||
- Do not rewrite broad areas of the codebase without clear justification from the current task.
|
- Dev servers use HTTP and bind to `0.0.0.0` for LAN access.
|
||||||
- Preserve user changes in the worktree and treat uncommitted files as active collaboration unless told otherwise.
|
- Avoid broad refactors unless the task explicitly asks for one.
|
||||||
- When creating commits, use the Conventional Commits format, for example `docs: update product planning`.
|
|
||||||
|
|
||||||
## Repository Layout
|
## 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.
|
- `backend/src/Socialize.Api/`: ASP.NET Core `net10.0` API using FastEndpoints, EF Core, PostgreSQL, ASP.NET Identity, and workflow modules.
|
||||||
- `.github/workflows/`: build/deploy pipelines for backend (Azure Web App) and frontend (Azure Static Web Apps).
|
- `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
|
## 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
|
Start infrastructure:
|
||||||
- Prereqs: Node/npm.
|
|
||||||
- Runtime configuration:
|
|
||||||
- frontend app config is loaded from `.env.development` and `.env.production`
|
|
||||||
- `frontend/src/config.js` is the single frontend source of truth for runtime env access
|
|
||||||
- Commands:
|
|
||||||
- `cd frontend && npm install`
|
|
||||||
- `npm run dev`
|
|
||||||
- `npm run build`
|
|
||||||
- Local dev server:
|
|
||||||
- `http://localhost:5173`
|
|
||||||
|
|
||||||
## Backend Architecture
|
```bash
|
||||||
### Composition Root
|
./scripts/start-infrastructure.sh
|
||||||
- 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.
|
|
||||||
|
|
||||||
### API Style
|
Run backend:
|
||||||
- FastEndpoints-based handlers.
|
|
||||||
- Pattern: request/response records + optional FluentValidation validator + handler class.
|
|
||||||
- Tagging via `Options(o => o.WithTags("..."))`.
|
|
||||||
- File upload handlers call `AllowFileUploads()`.
|
|
||||||
|
|
||||||
### Data Boundaries
|
```bash
|
||||||
- Separate DbContext per module:
|
./scripts/dev-backend.sh
|
||||||
- Identity, Workspaces, Clients, Projects, ContentItems, Assets, Comments, Approvals, Notifications.
|
```
|
||||||
- Migrations are module-scoped under each `Modules/*/Migrations` folder.
|
|
||||||
|
|
||||||
### Auth/Security
|
Run frontend:
|
||||||
- JWT is generated manually in `Infrastructure/Security/GenerateJwtToken.cs`.
|
|
||||||
- Refresh-token flow is implemented in Identity handlers (`/api/users/login`, `/api/users/refresh`).
|
```bash
|
||||||
- User claim helpers live in `Infrastructure/Security/ClaimsPrincipalExtensions.cs`.
|
./scripts/dev-frontend.sh
|
||||||
- Role-gated frontend routes currently use `Administrator` and `Manager` checks for settings access.
|
```
|
||||||
|
|
||||||
|
Update OpenAPI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/update-openapi.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Current Domain Modules
|
||||||
|
|
||||||
### Current Domain Modules
|
|
||||||
- `Identity`: authentication, refresh tokens, email verification, password reset, social login.
|
- `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.
|
- `Workspaces`: workspace membership, workspace settings, access scoping.
|
||||||
- `Clients`: client records and primary contacts tied to workspaces.
|
- `Clients`: client records and primary contacts tied to workspaces.
|
||||||
- `Projects`: project pipeline and client/project relationships.
|
- `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.
|
- `Comments`: discussion threads on reviewable work.
|
||||||
- `Approvals`: review decisions and workflow state transitions.
|
- `Approvals`: review decisions and workflow state transitions.
|
||||||
- `Notifications`: activity feed and unread workflow notifications.
|
- `Notifications`: activity feed and unread workflow notifications.
|
||||||
|
- `Feedback`: product feedback reports, screenshots, comments, activity, and developer review workflows.
|
||||||
|
|
||||||
## Frontend Architecture
|
## Task Discipline
|
||||||
### Bootstrap
|
|
||||||
- `frontend/src/main.js` wires Vue app + Pinia + Vuetify + Router + i18n + Google OAuth + Toasts.
|
|
||||||
- `frontend/src/config.js` is the app-facing runtime configuration module. Do not scatter `import.meta.env` reads across the app.
|
|
||||||
|
|
||||||
### Routing
|
Agents should work from task files in `docs/TASKS/`.
|
||||||
- Defined in `frontend/src/router/router.js`.
|
|
||||||
- Route guards enforce:
|
|
||||||
- `meta.requiresAuth`
|
|
||||||
- `meta.notAuthenticated`
|
|
||||||
- optional `meta.roles`
|
|
||||||
- Primary authenticated app routes live under `/app/*`.
|
|
||||||
|
|
||||||
### State Management
|
A good task:
|
||||||
- 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.
|
|
||||||
|
|
||||||
### API Client
|
- has a clear goal
|
||||||
- Axios client in `frontend/src/plugins/api.js`.
|
- names the relevant feature spec
|
||||||
- Injects bearer token, proactively refreshes near expiry, retries once on 401.
|
- has a small scope
|
||||||
|
- lists likely files
|
||||||
|
- lists validation commands
|
||||||
|
|
||||||
## High-Value Domains
|
If no task exists, create one before implementing a meaningful feature.
|
||||||
- Identity and social login (`backend/Modules/Identity/*`, `frontend/src/views/auth/*`).
|
|
||||||
- Workspace-scoped operations and role checks (`backend/Modules/Workspaces/*`, `frontend/src/stores/workspaceStore.js`, `frontend/src/router/router.js`).
|
|
||||||
- Client and project workflow (`backend/Modules/Clients/*`, `backend/Modules/Projects/*`, `frontend/src/views/app/ClientsView.vue`, `frontend/src/views/app/ProjectsView.vue`).
|
|
||||||
- Content review lifecycle (`backend/Modules/ContentItems/*`, `backend/Modules/Assets/*`, `backend/Modules/Comments/*`, `backend/Modules/Approvals/*`, `frontend/src/views/app/ContentItemsView.vue`, `frontend/src/views/app/ContentItemDetailView.vue`, `frontend/src/views/app/ReviewQueueView.vue`).
|
|
||||||
- Notifications and workflow awareness (`backend/Modules/Notifications/*`, `frontend/src/stores/notificationsStore.js`).
|
|
||||||
|
|
||||||
## Task-Driven Development With Agents
|
## Validation
|
||||||
Use `docs/tasks/TASK-*.md` files as LLM-friendly implementation tickets. A task file should be self-contained enough for a fresh agent to understand the desired change without relying on a long conversation.
|
|
||||||
|
|
||||||
A good task file defines:
|
Backend:
|
||||||
- objective and product context
|
|
||||||
- scope and out of scope
|
|
||||||
- backend requirements, API contract, validation, data, authorization
|
|
||||||
- frontend requirements, route/screen, components, state, API integration, UX states
|
|
||||||
- files likely involved
|
|
||||||
- acceptance criteria
|
|
||||||
- validation plan
|
|
||||||
- risks and open questions
|
|
||||||
|
|
||||||
Features are fullstack by default unless the task explicitly says otherwise. Do not assume a feature is backend-only. For user-facing work, define both backend and frontend behavior before implementation.
|
```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
|
```bash
|
||||||
1. Keep module boundaries intact. Do not couple DbContexts across modules.
|
cd frontend
|
||||||
2. When adding endpoints, follow existing FastEndpoints pattern with validator + explicit route + tag.
|
npm run build
|
||||||
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.
|
|
||||||
|
|
||||||
## Validation Checklist Before Finishing
|
Contract changes:
|
||||||
- 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`.
|
|
||||||
|
|
||||||
## Notes / Known Sharp Edges
|
```bash
|
||||||
- Frontend config should come through `.env.development` / `.env.production` and `frontend/src/config.js`; avoid direct `import.meta.env` reads in feature code.
|
./scripts/update-openapi.sh
|
||||||
- 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.
|
## 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.
|
||||||
|
|||||||
125
README.md
125
README.md
@@ -1,88 +1,101 @@
|
|||||||
# Socialize
|
# 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.
|
- Backend: .NET 10 Web API in `backend/src/Socialize.Api`
|
||||||
- `frontend/`: Vue 3 + Vite + Vuetify + Pinia SPA.
|
- Backend tests: `backend/tests/Socialize.Tests`
|
||||||
- `docs/`: product, planning, and archived project documentation.
|
- Frontend: Vue 3 + Vite + Vuetify + Pinia in `frontend`
|
||||||
|
- API contract: OpenAPI snapshot in `shared/openapi`
|
||||||
## Current Backend Modules
|
- Deployment: Docker Compose + Caddy
|
||||||
|
- Agentic workflow: specs, task files, and prompt templates under `docs`
|
||||||
- `Identity`
|
|
||||||
- `Workspaces`
|
|
||||||
- `Clients`
|
|
||||||
- `Projects`
|
|
||||||
- `ContentItems`
|
|
||||||
- `Assets`
|
|
||||||
- `Comments`
|
|
||||||
- `Approvals`
|
|
||||||
- `Notifications`
|
|
||||||
|
|
||||||
## Local Development
|
## Local Development
|
||||||
|
|
||||||
### Backend
|
Terminal 1:
|
||||||
|
|
||||||
Prerequisites:
|
|
||||||
|
|
||||||
- .NET 10 SDK
|
|
||||||
- Docker
|
|
||||||
|
|
||||||
Start infrastructure:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
|
||||||
./scripts/start-infrastructure.sh
|
./scripts/start-infrastructure.sh
|
||||||
|
./scripts/dev-backend.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the API:
|
Terminal 2:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
./scripts/dev-frontend.sh
|
||||||
dotnet run --project Socialize.Api.csproj
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Local backend URL:
|
Frontend:
|
||||||
|
|
||||||
- `http://localhost:5000`
|
```txt
|
||||||
- Swagger UI: `http://localhost:5000/api`
|
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`
|
## Update Frontend API Types
|
||||||
- `frontend/.env.production`
|
|
||||||
- `frontend/src/config.js`
|
|
||||||
|
|
||||||
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build backend/Socialize.slnx
|
||||||
|
dotnet test backend/Socialize.slnx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm run build
|
||||||
npm run dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Local frontend URL:
|
## Agentic Workflow
|
||||||
|
|
||||||
- `http://localhost:5173`
|
Start here:
|
||||||
|
|
||||||
## Validation
|
```txt
|
||||||
|
docs/AGENTIC_WORKFLOW.md
|
||||||
|
```
|
||||||
|
|
||||||
- Backend: `cd backend && dotnet build Socialize.Api.csproj`
|
Use feature specs, task files, and prompt templates instead of asking agents to work from vague chat history.
|
||||||
- Frontend: `cd frontend && npm run build`
|
|
||||||
|
|
||||||
## Docs
|
|
||||||
|
|
||||||
- [docs/README.md](/home/jbourdon/repos/social-media/docs/README.md)
|
|
||||||
- [docs/product/vision.md](/home/jbourdon/repos/social-media/docs/product/vision.md)
|
|
||||||
- [docs/product/glossary.md](/home/jbourdon/repos/social-media/docs/product/glossary.md)
|
|
||||||
- [docs/constraints.md](/home/jbourdon/repos/social-media/docs/constraints.md)
|
|
||||||
- [AGENTS.md](/home/jbourdon/repos/social-media/AGENTS.md)
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
# PROMPT TEMPLATES
|
# 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
|
## Purpose
|
||||||
This document standardizes how we interact with AI coding agents (Codex, Claude, etc).
|
This document standardizes how we interact with AI coding agents (Codex, Claude, etc).
|
||||||
|
|||||||
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,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,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,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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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,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,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,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,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,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();
|
|
||||||
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 \
|
dotnet ef migrations add \
|
||||||
--context "Socialize.Modules.${MODULE_NAME}.Data.${MODULE_NAME}DbContext" \
|
--context "Socialize.Modules.${MODULE_NAME}.Data.${MODULE_NAME}DbContext" \
|
||||||
--configuration Debug \
|
--configuration Debug \
|
||||||
|
--project "src/Socialize.Api/Socialize.Api.csproj" \
|
||||||
|
--startup-project "src/Socialize.Api/Socialize.Api.csproj" \
|
||||||
--output-dir "Modules/${MODULE_NAME}/Migrations" \
|
--output-dir "Modules/${MODULE_NAME}/Migrations" \
|
||||||
"$MIGRATION_NAME"
|
"$MIGRATION_NAME"
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ UPDATE_COMMAND=(
|
|||||||
dotnet ef database update
|
dotnet ef database update
|
||||||
--context "Socialize.Modules.${MODULE_NAME}.Data.${MODULE_NAME}DbContext"
|
--context "Socialize.Modules.${MODULE_NAME}.Data.${MODULE_NAME}DbContext"
|
||||||
--configuration Debug
|
--configuration Debug
|
||||||
|
--project "src/Socialize.Api/Socialize.Api.csproj"
|
||||||
|
--startup-project "src/Socialize.Api/Socialize.Api.csproj"
|
||||||
)
|
)
|
||||||
|
|
||||||
if [ -n "$TARGET_MIGRATION" ]; then
|
if [ -n "$TARGET_MIGRATION" ]; then
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Socialize.Common.Domain;
|
namespace Socialize.Api.Common.Domain;
|
||||||
|
|
||||||
public abstract class Entity
|
public abstract class Entity
|
||||||
{
|
{
|
||||||
68
backend/src/Socialize.Api/Data/AppDbContext.cs
Normal file
68
backend/src/Socialize.Api/Data/AppDbContext.cs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
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.Workspaces.Data;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Data;
|
||||||
|
|
||||||
|
public class AppDbContext(
|
||||||
|
DbContextOptions<AppDbContext> options)
|
||||||
|
: IdentityDbContext<User, Role, Guid>(options)
|
||||||
|
{
|
||||||
|
public DbSet<Organization> Organizations => Set<Organization>();
|
||||||
|
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>();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using Socialize.Data;
|
using Socialize.Api.Data;
|
||||||
using Socialize.Infrastructure.Security;
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.Facebook;
|
using Microsoft.AspNetCore.Authentication.Facebook;
|
||||||
using Microsoft.AspNetCore.Authentication.Google;
|
using Microsoft.AspNetCore.Authentication.Google;
|
||||||
@@ -49,7 +50,7 @@ public static class DependencyInjection
|
|||||||
{
|
{
|
||||||
using IServiceScope scope = app.ApplicationServices.CreateScope();
|
using IServiceScope scope = app.ApplicationServices.CreateScope();
|
||||||
await using AppDbContext context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
await using AppDbContext context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
await context.Database.EnsureCreatedAsync(cancellationToken);
|
await context.Database.MigrateAsync(cancellationToken);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
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;
|
||||||
|
|
||||||
|
public 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,4 +1,4 @@
|
|||||||
namespace Socialize.Infrastructure.BlobStorage.Contracts;
|
namespace Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||||
|
|
||||||
public static class CommonFileNames
|
public static class CommonFileNames
|
||||||
{
|
{
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace Socialize.Infrastructure.BlobStorage.Contracts;
|
namespace Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||||
|
|
||||||
public static class ContentTypes
|
public static class ContentTypes
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Socialize.Infrastructure.BlobStorage.Contracts;
|
namespace Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||||
|
|
||||||
public interface IBlobStorage
|
public interface IBlobStorage
|
||||||
{
|
{
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
namespace Socialize.Infrastructure.BlobStorage.Contracts;
|
namespace Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||||
|
|
||||||
public static class SubDirectoryNames
|
public static class SubDirectoryNames
|
||||||
{
|
{
|
||||||
public const string Profile = "profile";
|
public const string Profile = "profile";
|
||||||
public const string Contents = "contents";
|
public const string Contents = "contents";
|
||||||
public const string Albums = "albums";
|
public const string Albums = "albums";
|
||||||
|
public const string FeedbackScreenshots = "screenshots";
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
|
||||||
|
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Infrastructure.BlobStorage.Services;
|
||||||
|
|
||||||
|
public sealed class LocalBlobStorage(
|
||||||
|
IWebHostEnvironment environment,
|
||||||
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
IOptions<LocalBlobStorageOptions> options,
|
||||||
|
ILogger<LocalBlobStorage> logger)
|
||||||
|
: IBlobStorage
|
||||||
|
{
|
||||||
|
private const long MaxUploadSize = 10 * 1024 * 1024;
|
||||||
|
private const string ContentTypeMetadataSuffix = ".content-type";
|
||||||
|
|
||||||
|
private readonly LocalBlobStorageOptions _options = options.Value;
|
||||||
|
|
||||||
|
public async Task<string> UploadFileAsync(
|
||||||
|
string containerName,
|
||||||
|
string blobName,
|
||||||
|
Stream stream,
|
||||||
|
string contentType,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
logger.LogInformation(
|
||||||
|
"Blob storage: Uploaded [{BlobName}] to local container [{ContainerName}] with contentType [{ContentType}] and uri [{FileUri}]",
|
||||||
|
blobName,
|
||||||
|
containerName,
|
||||||
|
contentType,
|
||||||
|
fileUri);
|
||||||
|
|
||||||
|
return fileUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MemoryStream> DownloadFileAsync(
|
||||||
|
string containerName,
|
||||||
|
string blobName,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
|
||||||
|
return memoryStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
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([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar])];
|
||||||
|
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("/", StringComparison.Ordinal)
|
||||||
|
? normalized.TrimEnd('/')
|
||||||
|
: $"/{normalized.TrimEnd('/')}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Socialize.Infrastructure.Configuration;
|
namespace Socialize.Api.Infrastructure.Configuration;
|
||||||
|
|
||||||
public class WebsiteOptions
|
public class WebsiteOptions
|
||||||
{
|
{
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
public static class DependencyInjection
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
builder.Services.AddTransient<IEmailSender, ResendEmailSender>();
|
||||||
|
//builder.Services.AddTransient<IEmailSender, EmailSender>();
|
||||||
|
|
||||||
|
builder.Services.AddHttpClient();
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,37 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Socialize.Infrastructure.Security;
|
using Socialize.Api.Data;
|
||||||
using Socialize.Modules.Identity.Contracts;
|
using Socialize.Api.Infrastructure.Security;
|
||||||
using Socialize.Modules.Identity.Data;
|
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 Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace Socialize.Infrastructure.Development;
|
namespace Socialize.Api.Infrastructure.Development;
|
||||||
|
|
||||||
public static class DevelopmentSeedExtensions
|
public static class DevelopmentSeedExtensions
|
||||||
{
|
{
|
||||||
|
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 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 ScopedClientId = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||||
private static readonly Guid HiddenClientId = Guid.Parse("22222222-2222-2222-2222-333333333333");
|
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 ScopedCampaignId = Guid.Parse("33333333-3333-3333-3333-333333333333");
|
||||||
private static readonly Guid HiddenProjectId = Guid.Parse("33333333-3333-3333-3333-444444444444");
|
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 ScopedContentItemId = Guid.Parse("44444444-4444-4444-4444-444444444444");
|
||||||
private static readonly Guid HiddenContentItemId = Guid.Parse("44444444-4444-4444-4444-555555555555");
|
private static readonly Guid HiddenContentItemId = Guid.Parse("44444444-4444-4444-4444-555555555555");
|
||||||
private static readonly Guid ScopedAssetId = Guid.Parse("55555555-5555-5555-5555-555555555555");
|
private static readonly Guid ScopedAssetId = Guid.Parse("55555555-5555-5555-5555-555555555555");
|
||||||
@@ -41,8 +59,6 @@ public static class DevelopmentSeedExtensions
|
|||||||
UserManager userManager = scope.ServiceProvider.GetRequiredService<UserManager>();
|
UserManager userManager = scope.ServiceProvider.GetRequiredService<UserManager>();
|
||||||
AppDbContext dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
AppDbContext dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
await RemoveLegacyDevUserAsync(userManager);
|
|
||||||
|
|
||||||
User manager = await EnsureUserAsync(
|
User manager = await EnsureUserAsync(
|
||||||
userManager,
|
userManager,
|
||||||
id: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
id: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
||||||
@@ -91,9 +107,30 @@ public static class DevelopmentSeedExtensions
|
|||||||
[
|
[
|
||||||
new Claim(KnownClaims.WorkspaceScope, WorkspaceId.ToString()),
|
new Claim(KnownClaims.WorkspaceScope, WorkspaceId.ToString()),
|
||||||
new Claim(KnownClaims.ClientScope, ScopedClientId.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: "dev",
|
||||||
|
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(
|
await EnsureWorkspaceDataAsync(
|
||||||
manager.Id,
|
manager.Id,
|
||||||
clientUser.Id,
|
clientUser.Id,
|
||||||
@@ -104,19 +141,6 @@ public static class DevelopmentSeedExtensions
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task RemoveLegacyDevUserAsync(UserManager userManager)
|
|
||||||
{
|
|
||||||
User? legacyUser = await userManager.FindByNameAsync("dev")
|
|
||||||
?? await userManager.FindByEmailAsync("dev@socialize.local");
|
|
||||||
|
|
||||||
if (legacyUser is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await userManager.DeleteAsync(legacyUser);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<User> EnsureUserAsync(
|
private static async Task<User> EnsureUserAsync(
|
||||||
UserManager userManager,
|
UserManager userManager,
|
||||||
Guid id,
|
Guid id,
|
||||||
@@ -190,7 +214,7 @@ public static class DevelopmentSeedExtensions
|
|||||||
|
|
||||||
IList<Claim> existingClaims = await userManager.GetClaimsAsync(user);
|
IList<Claim> existingClaims = await userManager.GetClaimsAsync(user);
|
||||||
List<Claim> managedClaims = existingClaims
|
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();
|
.ToList();
|
||||||
|
|
||||||
foreach (Claim claim in managedClaims)
|
foreach (Claim claim in managedClaims)
|
||||||
@@ -214,6 +238,75 @@ public static class DevelopmentSeedExtensions
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.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(
|
private static async Task EnsureWorkspaceDataAsync(
|
||||||
Guid managerUserId,
|
Guid managerUserId,
|
||||||
Guid clientUserId,
|
Guid clientUserId,
|
||||||
@@ -221,33 +314,31 @@ public static class DevelopmentSeedExtensions
|
|||||||
AppDbContext dbContext,
|
AppDbContext dbContext,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Workspace? workspace = await dbContext.Workspaces
|
await UpsertWorkspaceAsync(
|
||||||
.SingleOrDefaultAsync(candidate => candidate.Id == WorkspaceId, cancellationToken);
|
dbContext,
|
||||||
if (workspace is null)
|
WorkspaceId,
|
||||||
{
|
OrganizationId,
|
||||||
workspace = new Workspace
|
managerUserId,
|
||||||
{
|
"Luma Coffee",
|
||||||
Id = WorkspaceId,
|
"America/Montreal",
|
||||||
Name = string.Empty,
|
"/images/seed/luma-coffee-logo.svg",
|
||||||
Slug = string.Empty,
|
cancellationToken);
|
||||||
TimeZone = string.Empty,
|
await UpsertWorkspaceAsync(
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
dbContext,
|
||||||
};
|
AtlasWorkspaceId,
|
||||||
dbContext.Workspaces.Add(workspace);
|
OrganizationId,
|
||||||
}
|
managerUserId,
|
||||||
|
"Atlas Bakery",
|
||||||
workspace.Name = "Northstar Studio";
|
"America/Montreal",
|
||||||
workspace.Slug = "northstar-studio";
|
"/images/seed/atlas-bakery-logo.svg",
|
||||||
workspace.OwnerUserId = managerUserId;
|
cancellationToken);
|
||||||
workspace.TimeZone = "America/Montreal";
|
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
await UpsertClientAsync(
|
await UpsertClientAsync(
|
||||||
dbContext,
|
dbContext,
|
||||||
ScopedClientId,
|
ScopedClientId,
|
||||||
"Luma Coffee",
|
"Luma Coffee",
|
||||||
"Active",
|
"Active",
|
||||||
"https://images.unsplash.com/photo-1511920170033-f8396924c348?auto=format&fit=crop&w=200&q=80",
|
"/images/seed/luma-coffee-logo.svg",
|
||||||
"Sofia Martin",
|
"Sofia Martin",
|
||||||
"client@socialize.local",
|
"client@socialize.local",
|
||||||
WorkspaceId,
|
WorkspaceId,
|
||||||
@@ -257,15 +348,15 @@ public static class DevelopmentSeedExtensions
|
|||||||
HiddenClientId,
|
HiddenClientId,
|
||||||
"Atlas Bakery",
|
"Atlas Bakery",
|
||||||
"Active",
|
"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 Cole",
|
||||||
"nina@atlasbakery.test",
|
"nina@atlasbakery.test",
|
||||||
WorkspaceId,
|
AtlasWorkspaceId,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
await UpsertProjectAsync(
|
await UpsertCampaignAsync(
|
||||||
dbContext,
|
dbContext,
|
||||||
ScopedProjectId,
|
ScopedCampaignId,
|
||||||
WorkspaceId,
|
WorkspaceId,
|
||||||
ScopedClientId,
|
ScopedClientId,
|
||||||
"Spring Launch",
|
"Spring Launch",
|
||||||
@@ -275,10 +366,10 @@ public static class DevelopmentSeedExtensions
|
|||||||
"Cross-channel launch campaign for the spring offer.",
|
"Cross-channel launch campaign for the spring offer.",
|
||||||
"Coordinate creative approvals before the final week.",
|
"Coordinate creative approvals before the final week.",
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
await UpsertProjectAsync(
|
await UpsertCampaignAsync(
|
||||||
dbContext,
|
dbContext,
|
||||||
HiddenProjectId,
|
HiddenCampaignId,
|
||||||
WorkspaceId,
|
AtlasWorkspaceId,
|
||||||
HiddenClientId,
|
HiddenClientId,
|
||||||
"Summer Retention",
|
"Summer Retention",
|
||||||
"Planned",
|
"Planned",
|
||||||
@@ -288,16 +379,44 @@ public static class DevelopmentSeedExtensions
|
|||||||
"Sequence email and paid social updates together.",
|
"Sequence email and paid social updates together.",
|
||||||
cancellationToken);
|
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(
|
await UpsertContentItemAsync(
|
||||||
dbContext,
|
dbContext,
|
||||||
ScopedContentItemId,
|
ScopedContentItemId,
|
||||||
WorkspaceId,
|
WorkspaceId,
|
||||||
ScopedClientId,
|
ScopedClientId,
|
||||||
ScopedProjectId,
|
ScopedCampaignId,
|
||||||
"Spring launch hero video",
|
"Spring launch hero video",
|
||||||
"Fresh seasonal menu launch across Instagram and TikTok.",
|
"Fresh seasonal menu launch across Instagram and TikTok.",
|
||||||
"Instagram Reel, TikTok",
|
"Luma Coffee Instagram, Luma Coffee TikTok",
|
||||||
"In client review",
|
"In approval",
|
||||||
DateTimeOffset.UtcNow.AddDays(3),
|
DateTimeOffset.UtcNow.AddDays(3),
|
||||||
"v3",
|
"v3",
|
||||||
3,
|
3,
|
||||||
@@ -305,22 +424,22 @@ public static class DevelopmentSeedExtensions
|
|||||||
await UpsertContentItemAsync(
|
await UpsertContentItemAsync(
|
||||||
dbContext,
|
dbContext,
|
||||||
HiddenContentItemId,
|
HiddenContentItemId,
|
||||||
WorkspaceId,
|
AtlasWorkspaceId,
|
||||||
HiddenClientId,
|
HiddenClientId,
|
||||||
HiddenProjectId,
|
HiddenCampaignId,
|
||||||
"Bakery loyalty carousel",
|
"Bakery loyalty carousel",
|
||||||
"Reward regular customers with a four-card retention carousel.",
|
"Reward regular customers with a four-card retention carousel.",
|
||||||
"Instagram Carousel",
|
"Atlas Bakery Instagram",
|
||||||
"Draft",
|
"Draft",
|
||||||
DateTimeOffset.UtcNow.AddDays(10),
|
DateTimeOffset.UtcNow.AddDays(10),
|
||||||
"v1",
|
"v1",
|
||||||
1,
|
1,
|
||||||
cancellationToken);
|
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-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.", "Instagram Reel, TikTok", "Addressed internal pacing feedback.", providerUserId, DateTimeOffset.UtcNow.AddDays(-3), 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.", "Instagram Reel, TikTok", "Client-facing draft after copy cleanup.", providerUserId, DateTimeOffset.UtcNow.AddDays(-1), 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.", "Instagram Carousel", "First draft.", managerUserId, DateTimeOffset.UtcNow.AddDays(-2), 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);
|
Asset? asset = await dbContext.Assets.SingleOrDefaultAsync(candidate => candidate.Id == ScopedAssetId, cancellationToken);
|
||||||
if (asset is null)
|
if (asset is null)
|
||||||
@@ -368,8 +487,6 @@ public static class DevelopmentSeedExtensions
|
|||||||
comment.AuthorDisplayName = "Sofia Martin";
|
comment.AuthorDisplayName = "Sofia Martin";
|
||||||
comment.AuthorEmail = "client@socialize.local";
|
comment.AuthorEmail = "client@socialize.local";
|
||||||
comment.Body = "Please tighten the opening three seconds and make the launch CTA more explicit.";
|
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);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
ApprovalRequest? approvalRequest = await dbContext.ApprovalRequests.SingleOrDefaultAsync(candidate => candidate.Id == ScopedApprovalRequestId, cancellationToken);
|
ApprovalRequest? approvalRequest = await dbContext.ApprovalRequests.SingleOrDefaultAsync(candidate => candidate.Id == ScopedApprovalRequestId, cancellationToken);
|
||||||
@@ -448,6 +565,38 @@ public static class DevelopmentSeedExtensions
|
|||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
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,
|
||||||
|
TimeZone = string.Empty,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
dbContext.Workspaces.Add(workspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace.Name = name;
|
||||||
|
workspace.OrganizationId = organizationId;
|
||||||
|
workspace.OwnerUserId = ownerUserId;
|
||||||
|
workspace.TimeZone = timeZone;
|
||||||
|
workspace.LogoUrl = logoUrl;
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task UpsertClientAsync(
|
private static async Task UpsertClientAsync(
|
||||||
AppDbContext dbContext,
|
AppDbContext dbContext,
|
||||||
Guid id,
|
Guid id,
|
||||||
@@ -481,7 +630,7 @@ public static class DevelopmentSeedExtensions
|
|||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task UpsertProjectAsync(
|
private static async Task UpsertCampaignAsync(
|
||||||
AppDbContext dbContext,
|
AppDbContext dbContext,
|
||||||
Guid id,
|
Guid id,
|
||||||
Guid workspaceId,
|
Guid workspaceId,
|
||||||
@@ -494,26 +643,57 @@ public static class DevelopmentSeedExtensions
|
|||||||
string? notes,
|
string? notes,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Project? project = await dbContext.Projects.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
|
Campaign? campaign = await dbContext.Campaigns.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
|
||||||
if (project is null)
|
if (campaign is null)
|
||||||
{
|
{
|
||||||
project = new Project
|
campaign = new Campaign
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
Name = string.Empty,
|
Name = string.Empty,
|
||||||
Status = string.Empty,
|
Status = string.Empty,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
};
|
};
|
||||||
dbContext.Projects.Add(project);
|
dbContext.Campaigns.Add(campaign);
|
||||||
}
|
}
|
||||||
project.WorkspaceId = workspaceId;
|
campaign.WorkspaceId = workspaceId;
|
||||||
project.ClientId = clientId;
|
campaign.ClientId = clientId;
|
||||||
project.Name = name;
|
campaign.Name = name;
|
||||||
project.Description = description;
|
campaign.Description = description;
|
||||||
project.Notes = notes;
|
campaign.Notes = notes;
|
||||||
project.Status = status;
|
campaign.Status = status;
|
||||||
project.StartDate = startDate;
|
campaign.StartDate = startDate;
|
||||||
project.EndDate = endDate;
|
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);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,7 +702,7 @@ public static class DevelopmentSeedExtensions
|
|||||||
Guid id,
|
Guid id,
|
||||||
Guid workspaceId,
|
Guid workspaceId,
|
||||||
Guid clientId,
|
Guid clientId,
|
||||||
Guid projectId,
|
Guid campaignId,
|
||||||
string title,
|
string title,
|
||||||
string publicationMessage,
|
string publicationMessage,
|
||||||
string publicationTargets,
|
string publicationTargets,
|
||||||
@@ -549,7 +729,7 @@ public static class DevelopmentSeedExtensions
|
|||||||
}
|
}
|
||||||
item.WorkspaceId = workspaceId;
|
item.WorkspaceId = workspaceId;
|
||||||
item.ClientId = clientId;
|
item.ClientId = clientId;
|
||||||
item.ProjectId = projectId;
|
item.CampaignId = campaignId;
|
||||||
item.Title = title;
|
item.Title = title;
|
||||||
item.PublicationMessage = publicationMessage;
|
item.PublicationMessage = publicationMessage;
|
||||||
item.PublicationTargets = publicationTargets;
|
item.PublicationTargets = publicationTargets;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Socialize.Infrastructure.Development;
|
namespace Socialize.Api.Infrastructure.Development;
|
||||||
|
|
||||||
public record DevelopmentSeedOptions
|
public record DevelopmentSeedOptions
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Socialize.Infrastructure.Emailer.Configuration;
|
namespace Socialize.Api.Infrastructure.Emailer.Configuration;
|
||||||
|
|
||||||
public class EmailerOptions
|
public class EmailerOptions
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Socialize.Infrastructure.Emailer.Contracts;
|
namespace Socialize.Api.Infrastructure.Emailer.Contracts;
|
||||||
|
|
||||||
public interface IEmailSender
|
public interface IEmailSender
|
||||||
{
|
{
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using Socialize.Infrastructure.Emailer.Contracts;
|
using Socialize.Api.Infrastructure.Emailer.Contracts;
|
||||||
|
|
||||||
namespace Socialize.Infrastructure.Emailer.Services;
|
namespace Socialize.Api.Infrastructure.Emailer.Services;
|
||||||
|
|
||||||
public class LoggerEmailSender(ILogger<IEmailSender> logger)
|
public class LoggerEmailSender(ILogger<IEmailSender> logger)
|
||||||
: IEmailSender
|
: IEmailSender
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
using Socialize.Infrastructure.Emailer.Configuration;
|
using Socialize.Api.Infrastructure.Emailer.Configuration;
|
||||||
using Socialize.Infrastructure.Emailer.Contracts;
|
using Socialize.Api.Infrastructure.Emailer.Contracts;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using PostmarkDotNet;
|
using PostmarkDotNet;
|
||||||
|
|
||||||
namespace Socialize.Infrastructure.Emailer.Services;
|
namespace Socialize.Api.Infrastructure.Emailer.Services;
|
||||||
|
|
||||||
public class PostmarkEmailSender : IEmailSender
|
public class PostmarkEmailSender : IEmailSender
|
||||||
{
|
{
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Socialize.Infrastructure.Emailer.Configuration;
|
using Socialize.Api.Infrastructure.Emailer.Configuration;
|
||||||
using Socialize.Infrastructure.Emailer.Contracts;
|
using Socialize.Api.Infrastructure.Emailer.Contracts;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace Socialize.Infrastructure.Emailer.Services;
|
namespace Socialize.Api.Infrastructure.Emailer.Services;
|
||||||
|
|
||||||
public class ResendEmailSender : IEmailSender
|
public class ResendEmailSender : IEmailSender
|
||||||
{
|
{
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace Socialize.Infrastructure.Payments.Stripe.Configuration;
|
namespace Socialize.Api.Infrastructure.Payments.Stripe.Configuration;
|
||||||
|
|
||||||
public class StripeOptions
|
public class StripeOptions
|
||||||
{
|
{
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Socialize.Api.Modules.Identity.Contracts;
|
||||||
|
using Socialize.Api.Modules.Organizations.Services;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Infrastructure.Security;
|
||||||
|
|
||||||
|
public sealed class AccessScopeService(
|
||||||
|
OrganizationAccessService organizationAccessService)
|
||||||
|
{
|
||||||
|
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 CanAccessCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
|
||||||
|
{
|
||||||
|
return IsManager(user)
|
||||||
|
|| (CanAccessClient(user, workspaceId, clientId) && user.GetCampaignScopeIds().Contains(campaignId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanContributeToCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
|
||||||
|
{
|
||||||
|
return IsManager(user) || (IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanReviewContent(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
|
||||||
|
{
|
||||||
|
return IsManager(user)
|
||||||
|
|| 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 CanAccessWorkspace(user, workspaceId)
|
||||||
|
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
||||||
|
user,
|
||||||
|
workspaceId,
|
||||||
|
OrganizationPermissions.AccessOwnedWorkspaces,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CanManageWorkspaceAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
Guid workspaceId,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
return IsManager(user)
|
||||||
|
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
||||||
|
user,
|
||||||
|
workspaceId,
|
||||||
|
OrganizationPermissions.ManageWorkspaces,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CanCreateWorkspaceAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
Guid organizationId,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
return IsManager(user)
|
||||||
|
|| await organizationAccessService.HasOrganizationPermissionAsync(
|
||||||
|
user,
|
||||||
|
organizationId,
|
||||||
|
OrganizationPermissions.CreateWorkspaces,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CanAccessClientAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
Guid workspaceId,
|
||||||
|
Guid clientId,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (IsManager(user) ||
|
||||||
|
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 (IsManager(user) ||
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
return IsManager(user)
|
||||||
|
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
||||||
|
user,
|
||||||
|
workspaceId,
|
||||||
|
OrganizationPermissions.ManageWorkspaces,
|
||||||
|
ct)
|
||||||
|
|| IsProvider(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CanReviewContentAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
Guid workspaceId,
|
||||||
|
Guid clientId,
|
||||||
|
Guid campaignId,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
return IsManager(user)
|
||||||
|
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
||||||
|
user,
|
||||||
|
workspaceId,
|
||||||
|
OrganizationPermissions.AccessOwnedWorkspaces,
|
||||||
|
ct)
|
||||||
|
|| IsProvider(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct)
|
||||||
|
|| IsClient(user) && await CanAccessClientAsync(user, workspaceId, clientId, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace Socialize.Infrastructure.Security;
|
namespace Socialize.Api.Infrastructure.Security;
|
||||||
|
|
||||||
public static class ClaimsPrincipalExtensions
|
public static class ClaimsPrincipalExtensions
|
||||||
{
|
{
|
||||||
@@ -23,9 +23,9 @@ public static class ClaimsPrincipalExtensions
|
|||||||
return claims.GetScopeIds(KnownClaims.ClientScope);
|
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)
|
public static string? GetPersona(this ClaimsPrincipal claims)
|
||||||
@@ -3,7 +3,7 @@ using System.Security.Claims;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
namespace Socialize.Infrastructure.Security;
|
namespace Socialize.Api.Infrastructure.Security;
|
||||||
|
|
||||||
public static class JwtTokenHelper
|
public static class JwtTokenHelper
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Socialize.Infrastructure.Security;
|
namespace Socialize.Api.Infrastructure.Security;
|
||||||
|
|
||||||
public static class KnownClaims
|
public static class KnownClaims
|
||||||
{
|
{
|
||||||
@@ -6,6 +6,6 @@ public static class KnownClaims
|
|||||||
public const string PortraitUrl = "portraitUrl";
|
public const string PortraitUrl = "portraitUrl";
|
||||||
public const string WorkspaceScope = "workspace";
|
public const string WorkspaceScope = "workspace";
|
||||||
public const string ClientScope = "client";
|
public const string ClientScope = "client";
|
||||||
public const string ProjectScope = "project";
|
public const string CampaignScope = "campaign";
|
||||||
public const string Persona = "persona";
|
public const string Persona = "persona";
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Socialize.Infrastructure.Security;
|
namespace Socialize.Api.Infrastructure.Security;
|
||||||
|
|
||||||
public class MissingClaimException(
|
public class MissingClaimException(
|
||||||
string claimName)
|
string claimName)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
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.
|
// If we need to add special characters we can alternate between 2 pools.
|
||||||
public static class PasswordGenerator
|
public static class PasswordGenerator
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
namespace Socialize.Infrastructure.Security;
|
namespace Socialize.Api.Infrastructure.Security;
|
||||||
|
|
||||||
public static class RefreshTokenGenerator
|
public static class RefreshTokenGenerator
|
||||||
{
|
{
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace Socialize.Infrastructure.YouTube;
|
namespace Socialize.Api.Infrastructure.YouTube;
|
||||||
|
|
||||||
public static class YouTubeUrlHelper
|
public static class YouTubeUrlHelper
|
||||||
{
|
{
|
||||||
1946
backend/src/Socialize.Api/Migrations/20260505204545_Initial.Designer.cs
generated
Normal file
1946
backend/src/Socialize.Api/Migrations/20260505204545_Initial.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
1283
backend/src/Socialize.Api/Migrations/20260505204545_Initial.cs
Normal file
1283
backend/src/Socialize.Api/Migrations/20260505204545_Initial.cs
Normal file
File diff suppressed because it is too large
Load Diff
1943
backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs
Normal file
1943
backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
namespace Socialize.Modules.Approvals.Data;
|
namespace Socialize.Api.Modules.Approvals.Data;
|
||||||
|
|
||||||
public class ApprovalDecision
|
public class ApprovalDecision
|
||||||
{
|
{
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.Approvals.Data;
|
||||||
|
|
||||||
|
public static class ApprovalModelConfiguration
|
||||||
|
{
|
||||||
|
public static ModelBuilder ConfigureApprovalsModule(this ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<ApprovalWorkflowInstance>(workflowInstance =>
|
||||||
|
{
|
||||||
|
workflowInstance.ToTable("ApprovalWorkflowInstances");
|
||||||
|
workflowInstance.HasKey(x => x.Id);
|
||||||
|
workflowInstance.Property(x => x.State).HasMaxLength(64).IsRequired();
|
||||||
|
workflowInstance.Property(x => x.ApprovalMode).HasMaxLength(64).IsRequired();
|
||||||
|
workflowInstance.Property(x => x.StartedAt)
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
workflowInstance.HasIndex(x => x.WorkspaceId);
|
||||||
|
workflowInstance.HasIndex(x => x.ContentItemId);
|
||||||
|
workflowInstance.HasIndex(x => new { x.ContentItemId, x.State })
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"State\" = 'Pending'");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<ApprovalRequest>(approvalRequest =>
|
||||||
|
{
|
||||||
|
approvalRequest.ToTable("ApprovalRequests");
|
||||||
|
approvalRequest.HasKey(x => x.Id);
|
||||||
|
approvalRequest.Property(x => x.WorkflowStepTargetType).HasMaxLength(32);
|
||||||
|
approvalRequest.Property(x => x.WorkflowStepTargetValue).HasMaxLength(128);
|
||||||
|
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.WorkflowInstanceId);
|
||||||
|
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<WorkspaceApprovalStepConfiguration>(approvalStep =>
|
||||||
|
{
|
||||||
|
approvalStep.ToTable("WorkspaceApprovalStepConfigurations");
|
||||||
|
approvalStep.HasKey(x => x.Id);
|
||||||
|
approvalStep.Property(x => x.Name).HasMaxLength(128).IsRequired();
|
||||||
|
approvalStep.Property(x => x.TargetType).HasMaxLength(32).IsRequired();
|
||||||
|
approvalStep.Property(x => x.TargetValue).HasMaxLength(128).IsRequired();
|
||||||
|
approvalStep.Property(x => x.RequiredApproverCount).HasDefaultValue(1);
|
||||||
|
approvalStep.Property(x => x.CreatedAt)
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
approvalStep.HasIndex(x => x.WorkspaceId);
|
||||||
|
approvalStep.HasIndex(x => new { x.WorkspaceId, x.SortOrder }).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
|
return modelBuilder;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
namespace Socialize.Modules.Approvals.Data;
|
namespace Socialize.Api.Modules.Approvals.Data;
|
||||||
|
|
||||||
public class ApprovalRequest
|
public class ApprovalRequest
|
||||||
{
|
{
|
||||||
public Guid Id { get; init; }
|
public Guid Id { get; init; }
|
||||||
public Guid WorkspaceId { get; set; }
|
public Guid WorkspaceId { get; set; }
|
||||||
public Guid ContentItemId { get; set; }
|
public Guid ContentItemId { get; set; }
|
||||||
|
public Guid? WorkflowInstanceId { get; set; }
|
||||||
|
public int? WorkflowStepSortOrder { get; set; }
|
||||||
|
public string? WorkflowStepTargetType { get; set; }
|
||||||
|
public string? WorkflowStepTargetValue { get; set; }
|
||||||
|
public int? WorkflowStepRequiredApproverCount { get; set; }
|
||||||
public required string Stage { get; set; }
|
public required string Stage { get; set; }
|
||||||
public required string ReviewerName { get; set; }
|
public required string ReviewerName { get; set; }
|
||||||
public required string ReviewerEmail { get; set; }
|
public required string ReviewerEmail { get; set; }
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Socialize.Api.Modules.Approvals.Data;
|
||||||
|
|
||||||
|
public class ApprovalWorkflowInstance
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
public Guid WorkspaceId { get; set; }
|
||||||
|
public Guid ContentItemId { get; set; }
|
||||||
|
public required string State { get; set; }
|
||||||
|
public required string ApprovalMode { get; set; }
|
||||||
|
public DateTimeOffset StartedAt { get; init; }
|
||||||
|
public DateTimeOffset? CompletedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Socialize.Api.Modules.Approvals.Data;
|
||||||
|
|
||||||
|
public class WorkspaceApprovalStepConfiguration
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
public Guid WorkspaceId { get; set; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public required string TargetType { get; set; }
|
||||||
|
public required string TargetValue { get; set; }
|
||||||
|
public int RequiredApproverCount { get; set; } = 1;
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
using Socialize.Modules.Approvals.Data;
|
using Socialize.Api.Modules.Approvals.Services;
|
||||||
|
|
||||||
namespace Socialize.Modules.Approvals;
|
namespace Socialize.Api.Modules.Approvals;
|
||||||
|
|
||||||
public static class DependencyInjection
|
public static class DependencyInjection
|
||||||
{
|
{
|
||||||
public static WebApplicationBuilder AddApprovalsModule(
|
public static WebApplicationBuilder AddApprovalsModule(
|
||||||
this WebApplicationBuilder builder)
|
this WebApplicationBuilder builder)
|
||||||
{
|
{
|
||||||
|
builder.Services.AddScoped<ApprovalWorkflowRuntimeService>();
|
||||||
|
|
||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
using Socialize.Infrastructure.Security;
|
using FastEndpoints;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Modules.Approvals.Data;
|
||||||
|
using Socialize.Api.Modules.ContentItems.Data;
|
||||||
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
|
||||||
namespace Socialize.Modules.Approvals.Handlers;
|
namespace Socialize.Api.Modules.Approvals.Handlers;
|
||||||
|
|
||||||
public record GetApprovalsRequest(Guid ContentItemId);
|
public record GetApprovalsRequest(Guid ContentItemId);
|
||||||
|
|
||||||
@@ -19,6 +24,11 @@ public record ApprovalRequestDto(
|
|||||||
Guid Id,
|
Guid Id,
|
||||||
Guid WorkspaceId,
|
Guid WorkspaceId,
|
||||||
Guid ContentItemId,
|
Guid ContentItemId,
|
||||||
|
Guid? WorkflowInstanceId,
|
||||||
|
int? WorkflowStepSortOrder,
|
||||||
|
string? WorkflowStepTargetType,
|
||||||
|
string? WorkflowStepTargetValue,
|
||||||
|
int? WorkflowStepRequiredApproverCount,
|
||||||
string Stage,
|
string Stage,
|
||||||
string ReviewerName,
|
string ReviewerName,
|
||||||
string ReviewerEmail,
|
string ReviewerEmail,
|
||||||
@@ -51,7 +61,7 @@ public class GetApprovalsHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
|
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
|
||||||
{
|
{
|
||||||
await SendForbiddenAsync(ct);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
@@ -60,6 +70,7 @@ public class GetApprovalsHandler(
|
|||||||
List<ApprovalRequest> approvals = await dbContext.ApprovalRequests
|
List<ApprovalRequest> approvals = await dbContext.ApprovalRequests
|
||||||
.Where(approval => approval.ContentItemId == request.ContentItemId)
|
.Where(approval => approval.ContentItemId == request.ContentItemId)
|
||||||
.OrderByDescending(approval => approval.SentAt)
|
.OrderByDescending(approval => approval.SentAt)
|
||||||
|
.ThenBy(approval => approval.WorkflowStepSortOrder)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
List<Guid> approvalIds = approvals
|
List<Guid> approvalIds = approvals
|
||||||
@@ -86,6 +97,11 @@ public class GetApprovalsHandler(
|
|||||||
approval.Id,
|
approval.Id,
|
||||||
approval.WorkspaceId,
|
approval.WorkspaceId,
|
||||||
approval.ContentItemId,
|
approval.ContentItemId,
|
||||||
|
approval.WorkflowInstanceId,
|
||||||
|
approval.WorkflowStepSortOrder,
|
||||||
|
approval.WorkflowStepTargetType,
|
||||||
|
approval.WorkflowStepTargetValue,
|
||||||
|
approval.WorkflowStepRequiredApproverCount,
|
||||||
approval.Stage,
|
approval.Stage,
|
||||||
approval.ReviewerName,
|
approval.ReviewerName,
|
||||||
approval.ReviewerEmail,
|
approval.ReviewerEmail,
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
using Socialize.Infrastructure.Security;
|
using FastEndpoints;
|
||||||
using Socialize.Modules.Notifications.Contracts;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Socialize.Api.Modules.ContentItems.Data;
|
||||||
|
using Socialize.Api.Modules.ContentItems.Contracts;
|
||||||
|
using Socialize.Api.Modules.Approvals.Data;
|
||||||
|
using Socialize.Api.Modules.Approvals.Services;
|
||||||
|
using Socialize.Api.Modules.Notifications.Contracts;
|
||||||
|
using Socialize.Api.Modules.Workspaces.Data;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace Socialize.Modules.Approvals.Handlers;
|
namespace Socialize.Api.Modules.Approvals.Handlers;
|
||||||
|
|
||||||
public record SubmitApprovalDecisionRequest(
|
public record SubmitApprovalDecisionRequest(
|
||||||
string Decision,
|
string Decision,
|
||||||
string? Comment,
|
|
||||||
string? ReviewerName,
|
string? ReviewerName,
|
||||||
string? ReviewerEmail);
|
string? ReviewerEmail);
|
||||||
|
|
||||||
@@ -14,8 +22,10 @@ public class SubmitApprovalDecisionRequestValidator
|
|||||||
{
|
{
|
||||||
public SubmitApprovalDecisionRequestValidator()
|
public SubmitApprovalDecisionRequestValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Decision).NotEmpty().MaximumLength(64);
|
RuleFor(x => x.Decision)
|
||||||
RuleFor(x => x.Comment).MaximumLength(2048);
|
.NotEmpty()
|
||||||
|
.Equal("Approved")
|
||||||
|
.WithMessage("Only approved decisions are supported.");
|
||||||
RuleFor(x => x.ReviewerName).MaximumLength(256);
|
RuleFor(x => x.ReviewerName).MaximumLength(256);
|
||||||
RuleFor(x => x.ReviewerEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ReviewerEmail));
|
RuleFor(x => x.ReviewerEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ReviewerEmail));
|
||||||
}
|
}
|
||||||
@@ -24,6 +34,8 @@ public class SubmitApprovalDecisionRequestValidator
|
|||||||
public class SubmitApprovalDecisionHandler(
|
public class SubmitApprovalDecisionHandler(
|
||||||
AppDbContext dbContext,
|
AppDbContext dbContext,
|
||||||
AccessScopeService accessScopeService,
|
AccessScopeService accessScopeService,
|
||||||
|
ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService,
|
||||||
|
IContentItemActivityWriter activityWriter,
|
||||||
INotificationEventWriter notificationEventWriter)
|
INotificationEventWriter notificationEventWriter)
|
||||||
: Endpoint<SubmitApprovalDecisionRequest, ApprovalRequestDto>
|
: Endpoint<SubmitApprovalDecisionRequest, ApprovalRequestDto>
|
||||||
{
|
{
|
||||||
@@ -53,12 +65,19 @@ public class SubmitApprovalDecisionHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (User?.Identity?.IsAuthenticated == true &&
|
if (User?.Identity?.IsAuthenticated == true &&
|
||||||
!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
|
!await accessScopeService.CanReviewContentAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
|
||||||
{
|
{
|
||||||
await SendForbiddenAsync(ct);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == contentItem.WorkspaceId, ct);
|
||||||
|
if (workspace is null)
|
||||||
|
{
|
||||||
|
await SendNotFoundAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
string normalizedDecision = request.Decision.Trim();
|
string normalizedDecision = request.Decision.Trim();
|
||||||
string decidedByName = User?.Identity?.IsAuthenticated == true
|
string decidedByName = User?.Identity?.IsAuthenticated == true
|
||||||
? User.GetAlias() ?? User.GetName()
|
? User.GetAlias() ?? User.GetName()
|
||||||
@@ -72,40 +91,56 @@ public class SubmitApprovalDecisionHandler(
|
|||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
ApprovalRequestId = approval.Id,
|
ApprovalRequestId = approval.Id,
|
||||||
Decision = normalizedDecision,
|
Decision = normalizedDecision,
|
||||||
Comment = string.IsNullOrWhiteSpace(request.Comment) ? null : request.Comment.Trim(),
|
Comment = null,
|
||||||
DecidedByUserId = User?.Identity?.IsAuthenticated == true ? User.GetUserId() : null,
|
DecidedByUserId = User?.Identity?.IsAuthenticated == true ? User.GetUserId() : null,
|
||||||
DecidedByName = decidedByName,
|
DecidedByName = decidedByName,
|
||||||
DecidedByEmail = decidedByEmail,
|
DecidedByEmail = decidedByEmail,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ApprovalWorkflowDecisionResult workflowDecisionResult = await approvalWorkflowRuntimeService
|
||||||
|
.ApplyWorkflowStepDecisionAsync(approval, contentItem, workspace, User!, decision, ct);
|
||||||
|
|
||||||
|
if (!workflowDecisionResult.Succeeded)
|
||||||
|
{
|
||||||
|
AddError(request => request.Decision, workflowDecisionResult.ErrorMessage ?? "The approval decision could not be recorded.");
|
||||||
|
await SendErrorsAsync(workflowDecisionResult.StatusCode, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workflowDecisionResult.IsWorkflowStep)
|
||||||
|
{
|
||||||
approval.State = normalizedDecision;
|
approval.State = normalizedDecision;
|
||||||
approval.CompletedAt = DateTimeOffset.UtcNow;
|
approval.CompletedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
if (approval.Stage == "Internal")
|
if (normalizedDecision == "Approved")
|
||||||
{
|
{
|
||||||
contentItem.Status = normalizedDecision switch
|
contentItem.Status = ApprovalWorkflowRules.GetFinalApprovalStatus(
|
||||||
{
|
workspace.SchedulePostsAutomaticallyOnApproval,
|
||||||
"Approved" => "Ready for client review",
|
contentItem.DueDate);
|
||||||
"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);
|
dbContext.ApprovalDecisions.Add(decision);
|
||||||
await dbContext.SaveChangesAsync(ct);
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
await activityWriter.WriteAsync(
|
||||||
|
new ContentItemActivityWriteModel(
|
||||||
|
approval.WorkspaceId,
|
||||||
|
approval.ContentItemId,
|
||||||
|
"approval.decision.recorded",
|
||||||
|
"ApprovalDecision",
|
||||||
|
decision.Id,
|
||||||
|
$"{decidedByName} recorded {normalizedDecision} for {contentItem.Title}.",
|
||||||
|
decision.DecidedByUserId,
|
||||||
|
decidedByEmail,
|
||||||
|
JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
stage = approval.Stage,
|
||||||
|
status = contentItem.Status,
|
||||||
|
decision = normalizedDecision,
|
||||||
|
})),
|
||||||
|
ct);
|
||||||
|
|
||||||
await notificationEventWriter.WriteAsync(
|
await notificationEventWriter.WriteAsync(
|
||||||
new NotificationEventWriteModel(
|
new NotificationEventWriteModel(
|
||||||
approval.WorkspaceId,
|
approval.WorkspaceId,
|
||||||
@@ -118,6 +153,7 @@ public class SubmitApprovalDecisionHandler(
|
|||||||
decidedByEmail,
|
decidedByEmail,
|
||||||
$$"""{"stage":"{{approval.Stage}}","status":"{{contentItem.Status}}"}"""),
|
$$"""{"stage":"{{approval.Stage}}","status":"{{contentItem.Status}}"}"""),
|
||||||
ct);
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
List<ApprovalDecision> decisions = await dbContext.ApprovalDecisions
|
List<ApprovalDecision> decisions = await dbContext.ApprovalDecisions
|
||||||
.Where(candidate => candidate.ApprovalRequestId == approval.Id)
|
.Where(candidate => candidate.ApprovalRequestId == approval.Id)
|
||||||
@@ -153,6 +189,11 @@ public class SubmitApprovalDecisionHandler(
|
|||||||
approval.Id,
|
approval.Id,
|
||||||
approval.WorkspaceId,
|
approval.WorkspaceId,
|
||||||
approval.ContentItemId,
|
approval.ContentItemId,
|
||||||
|
approval.WorkflowInstanceId,
|
||||||
|
approval.WorkflowStepSortOrder,
|
||||||
|
approval.WorkflowStepTargetType,
|
||||||
|
approval.WorkflowStepTargetValue,
|
||||||
|
approval.WorkflowStepRequiredApproverCount,
|
||||||
approval.Stage,
|
approval.Stage,
|
||||||
approval.ReviewerName,
|
approval.ReviewerName,
|
||||||
approval.ReviewerEmail,
|
approval.ReviewerEmail,
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using Socialize.Api.Modules.Identity.Contracts;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.Approvals.Services;
|
||||||
|
|
||||||
|
public static class ApprovalStepTargetTypes
|
||||||
|
{
|
||||||
|
public const string Role = "Role";
|
||||||
|
public const string Membership = "Membership";
|
||||||
|
public const string Member = "Member";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ApprovalMembershipTargets
|
||||||
|
{
|
||||||
|
public const string Team = "Team";
|
||||||
|
public const string Client = "Client";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ApprovalStepConfigurationRules
|
||||||
|
{
|
||||||
|
public static readonly IReadOnlySet<string> AllowedTargetTypes = new HashSet<string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
ApprovalStepTargetTypes.Role,
|
||||||
|
ApprovalStepTargetTypes.Membership,
|
||||||
|
ApprovalStepTargetTypes.Member,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static readonly IReadOnlySet<string> AllowedRoleTargets = new HashSet<string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
KnownRoles.Administrator,
|
||||||
|
KnownRoles.Manager,
|
||||||
|
KnownRoles.WorkspaceMember,
|
||||||
|
KnownRoles.Client,
|
||||||
|
KnownRoles.Provider,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static readonly IReadOnlySet<string> AllowedMembershipTargets = new HashSet<string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
ApprovalMembershipTargets.Team,
|
||||||
|
ApprovalMembershipTargets.Client,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool IsValidTargetType(string? targetType)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrWhiteSpace(targetType) && AllowedTargetTypes.Contains(targetType.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsValidRoleTarget(string? targetValue)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrWhiteSpace(targetValue) && AllowedRoleTargets.Contains(targetValue.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsValidMembershipTarget(string? targetValue)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrWhiteSpace(targetValue) && AllowedMembershipTargets.Contains(targetValue.Trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using Socialize.Api.Modules.Identity.Contracts;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.Approvals.Services;
|
||||||
|
|
||||||
|
public static class ApprovalModes
|
||||||
|
{
|
||||||
|
public const string None = "None";
|
||||||
|
public const string Optional = "Optional";
|
||||||
|
public const string Required = "Required";
|
||||||
|
public const string MultiLevel = "Multi-level";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ApprovalWorkflowRules
|
||||||
|
{
|
||||||
|
public static bool BlocksManualApprovedOrScheduledStatus(string approvalMode)
|
||||||
|
{
|
||||||
|
return approvalMode is ApprovalModes.Required or ApprovalModes.MultiLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsApprovalCompletionStatus(string status)
|
||||||
|
{
|
||||||
|
return status is "Approved" or "Scheduled";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetFinalApprovalStatus(bool schedulePostsAutomaticallyOnApproval, DateTimeOffset? plannedPublishDate)
|
||||||
|
{
|
||||||
|
return schedulePostsAutomaticallyOnApproval && plannedPublishDate.HasValue
|
||||||
|
? "Scheduled"
|
||||||
|
: "Approved";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool HasRequiredStepApprovals(int approvedDecisionCount, int requiredApproverCount)
|
||||||
|
{
|
||||||
|
return approvedDecisionCount >= Math.Max(1, requiredApproverCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool CanApproveWorkflowStep(
|
||||||
|
bool isAdministrator,
|
||||||
|
bool hasWorkspaceAccess,
|
||||||
|
IReadOnlyCollection<string> userRoles,
|
||||||
|
Guid userId,
|
||||||
|
string? targetType,
|
||||||
|
string? targetValue)
|
||||||
|
{
|
||||||
|
if (isAdministrator)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasWorkspaceAccess ||
|
||||||
|
string.IsNullOrWhiteSpace(targetType) ||
|
||||||
|
string.IsNullOrWhiteSpace(targetValue))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetType switch
|
||||||
|
{
|
||||||
|
ApprovalStepTargetTypes.Role => userRoles.Contains(targetValue),
|
||||||
|
ApprovalStepTargetTypes.Membership => MatchesMembershipTarget(userRoles, targetValue),
|
||||||
|
ApprovalStepTargetTypes.Member => ParseMemberTargetIds(targetValue).Contains(userId),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyCollection<Guid> ParseMemberTargetIds(string? targetValue)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(targetValue))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetValue
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Select(value => Guid.TryParse(value, out Guid memberUserId) ? memberUserId : Guid.Empty)
|
||||||
|
.Where(memberUserId => memberUserId != Guid.Empty)
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string FormatMemberTargetValue(IEnumerable<Guid> memberUserIds)
|
||||||
|
{
|
||||||
|
return string.Join(",", memberUserIds.Distinct().OrderBy(memberUserId => memberUserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesMembershipTarget(
|
||||||
|
IReadOnlyCollection<string> userRoles,
|
||||||
|
string targetValue)
|
||||||
|
{
|
||||||
|
return targetValue switch
|
||||||
|
{
|
||||||
|
ApprovalMembershipTargets.Client => userRoles.Contains(KnownRoles.Client),
|
||||||
|
ApprovalMembershipTargets.Team => !userRoles.Contains(KnownRoles.Client),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Socialize.Api.Modules.Approvals.Data;
|
||||||
|
using Socialize.Api.Modules.ContentItems.Data;
|
||||||
|
using Socialize.Api.Modules.Identity.Contracts;
|
||||||
|
using Socialize.Api.Modules.Notifications.Contracts;
|
||||||
|
using Socialize.Api.Modules.Workspaces.Data;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.Approvals.Services;
|
||||||
|
|
||||||
|
public record ApprovalWorkflowStartResult(bool Succeeded, string? ErrorMessage);
|
||||||
|
|
||||||
|
public record ApprovalWorkflowDecisionResult(
|
||||||
|
bool Succeeded,
|
||||||
|
string? ErrorMessage,
|
||||||
|
int StatusCode,
|
||||||
|
bool IsWorkflowStep);
|
||||||
|
|
||||||
|
public class ApprovalWorkflowRuntimeService(
|
||||||
|
AppDbContext dbContext,
|
||||||
|
INotificationEventWriter notificationEventWriter)
|
||||||
|
{
|
||||||
|
private const string PendingState = "Pending";
|
||||||
|
private const string ApprovedState = "Approved";
|
||||||
|
|
||||||
|
public async Task<ApprovalWorkflowStartResult> StartMultiLevelWorkflowAsync(
|
||||||
|
ContentItem contentItem,
|
||||||
|
Workspace workspace,
|
||||||
|
Guid requestedByUserId,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (workspace.ApprovalMode != ApprovalModes.MultiLevel)
|
||||||
|
{
|
||||||
|
return new ApprovalWorkflowStartResult(false, "The workspace is not configured for multi-level approval.");
|
||||||
|
}
|
||||||
|
|
||||||
|
ApprovalWorkflowInstance? activeWorkflow = await dbContext.ApprovalWorkflowInstances
|
||||||
|
.SingleOrDefaultAsync(
|
||||||
|
workflow => workflow.ContentItemId == contentItem.Id && workflow.State == PendingState,
|
||||||
|
ct);
|
||||||
|
if (activeWorkflow is not null)
|
||||||
|
{
|
||||||
|
contentItem.Status = "In approval";
|
||||||
|
return new ApprovalWorkflowStartResult(true, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<WorkspaceApprovalStepConfiguration> configuredSteps = await dbContext.WorkspaceApprovalStepConfigurations
|
||||||
|
.Where(step => step.WorkspaceId == workspace.Id)
|
||||||
|
.OrderBy(step => step.SortOrder)
|
||||||
|
.ThenBy(step => step.Name)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (configuredSteps.Count == 0)
|
||||||
|
{
|
||||||
|
return new ApprovalWorkflowStartResult(false, "Multi-level approval requires at least one configured approval step.");
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||||
|
var workflowInstance = new ApprovalWorkflowInstance
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
WorkspaceId = workspace.Id,
|
||||||
|
ContentItemId = contentItem.Id,
|
||||||
|
State = PendingState,
|
||||||
|
ApprovalMode = workspace.ApprovalMode,
|
||||||
|
StartedAt = now,
|
||||||
|
};
|
||||||
|
|
||||||
|
List<ApprovalRequest> workflowSteps = configuredSteps
|
||||||
|
.Select((step, index) => new ApprovalRequest
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
WorkspaceId = workspace.Id,
|
||||||
|
ContentItemId = contentItem.Id,
|
||||||
|
WorkflowInstanceId = workflowInstance.Id,
|
||||||
|
WorkflowStepSortOrder = index,
|
||||||
|
WorkflowStepTargetType = step.TargetType,
|
||||||
|
WorkflowStepTargetValue = step.TargetValue,
|
||||||
|
WorkflowStepRequiredApproverCount = step.RequiredApproverCount,
|
||||||
|
Stage = step.Name,
|
||||||
|
ReviewerName = FormatStepTarget(step),
|
||||||
|
ReviewerEmail = string.Empty,
|
||||||
|
RequestedByUserId = requestedByUserId,
|
||||||
|
DueAt = contentItem.DueDate,
|
||||||
|
State = PendingState,
|
||||||
|
AccessToken = CreateAccessToken(),
|
||||||
|
SentAt = now,
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
dbContext.ApprovalWorkflowInstances.Add(workflowInstance);
|
||||||
|
dbContext.ApprovalRequests.AddRange(workflowSteps);
|
||||||
|
contentItem.Status = "In approval";
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
await NotifyCurrentStepApproversAsync(workflowSteps[0], contentItem, ct);
|
||||||
|
|
||||||
|
return new ApprovalWorkflowStartResult(true, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApprovalWorkflowDecisionResult> ApplyWorkflowStepDecisionAsync(
|
||||||
|
ApprovalRequest approval,
|
||||||
|
ContentItem contentItem,
|
||||||
|
Workspace workspace,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
ApprovalDecision decision,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!approval.WorkflowInstanceId.HasValue)
|
||||||
|
{
|
||||||
|
return new ApprovalWorkflowDecisionResult(true, null, StatusCodes.Status200OK, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.Identity?.IsAuthenticated != true)
|
||||||
|
{
|
||||||
|
return new ApprovalWorkflowDecisionResult(false, "Multi-level approval steps require an authenticated approver.", StatusCodes.Status401Unauthorized, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await CanApproveStepAsync(user, approval, workspace.Id, ct))
|
||||||
|
{
|
||||||
|
return new ApprovalWorkflowDecisionResult(false, "You cannot approve the current workflow step.", StatusCodes.Status403Forbidden, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
ApprovalRequest? currentStep = await GetCurrentPendingStepAsync(approval.WorkflowInstanceId.Value, ct);
|
||||||
|
if (currentStep?.Id != approval.Id)
|
||||||
|
{
|
||||||
|
return new ApprovalWorkflowDecisionResult(false, "Only the current pending approval step can be approved.", StatusCodes.Status409Conflict, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Guid currentUserId = user.GetUserId();
|
||||||
|
bool alreadyApproved = await dbContext.ApprovalDecisions.AnyAsync(
|
||||||
|
candidate => candidate.ApprovalRequestId == approval.Id &&
|
||||||
|
candidate.DecidedByUserId == currentUserId &&
|
||||||
|
candidate.Decision == ApprovedState,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
if (alreadyApproved)
|
||||||
|
{
|
||||||
|
return new ApprovalWorkflowDecisionResult(false, "You have already approved this workflow step.", StatusCodes.Status409Conflict, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
dbContext.ApprovalDecisions.Add(decision);
|
||||||
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
int approvedCount = await dbContext.ApprovalDecisions
|
||||||
|
.Where(candidate => candidate.ApprovalRequestId == approval.Id && candidate.Decision == ApprovedState)
|
||||||
|
.Select(candidate => candidate.DecidedByUserId.HasValue
|
||||||
|
? candidate.DecidedByUserId.Value.ToString()
|
||||||
|
: candidate.DecidedByEmail.ToLower())
|
||||||
|
.Distinct()
|
||||||
|
.CountAsync(ct);
|
||||||
|
|
||||||
|
int requiredApproverCount = approval.WorkflowStepRequiredApproverCount ?? 1;
|
||||||
|
if (!ApprovalWorkflowRules.HasRequiredStepApprovals(approvedCount, requiredApproverCount))
|
||||||
|
{
|
||||||
|
return new ApprovalWorkflowDecisionResult(true, null, StatusCodes.Status200OK, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
approval.State = ApprovedState;
|
||||||
|
approval.CompletedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
ApprovalRequest? nextStep = await dbContext.ApprovalRequests
|
||||||
|
.Where(candidate => candidate.WorkflowInstanceId == approval.WorkflowInstanceId &&
|
||||||
|
candidate.State == PendingState &&
|
||||||
|
candidate.Id != approval.Id)
|
||||||
|
.OrderBy(candidate => candidate.WorkflowStepSortOrder)
|
||||||
|
.ThenBy(candidate => candidate.SentAt)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
if (nextStep is null)
|
||||||
|
{
|
||||||
|
ApprovalWorkflowInstance? workflowInstance = await dbContext.ApprovalWorkflowInstances
|
||||||
|
.SingleOrDefaultAsync(candidate => candidate.Id == approval.WorkflowInstanceId.Value, ct);
|
||||||
|
if (workflowInstance is null)
|
||||||
|
{
|
||||||
|
return new ApprovalWorkflowDecisionResult(false, "The approval workflow instance could not be found.", StatusCodes.Status404NotFound, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
workflowInstance.State = ApprovedState;
|
||||||
|
workflowInstance.CompletedAt = DateTimeOffset.UtcNow;
|
||||||
|
contentItem.Status = ApprovalWorkflowRules.GetFinalApprovalStatus(
|
||||||
|
workspace.SchedulePostsAutomaticallyOnApproval,
|
||||||
|
contentItem.DueDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
if (nextStep is null)
|
||||||
|
{
|
||||||
|
await NotifyPublishUsersAsync(approval, contentItem, ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await NotifyCurrentStepApproversAsync(nextStep, contentItem, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ApprovalWorkflowDecisionResult(true, null, StatusCodes.Status200OK, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> HasCompletedMultiLevelWorkflowAsync(Guid contentItemId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await dbContext.ApprovalWorkflowInstances.AnyAsync(
|
||||||
|
workflow => workflow.ContentItemId == contentItemId && workflow.State == ApprovedState,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ApprovalRequest?> GetCurrentPendingStepAsync(Guid workflowInstanceId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await dbContext.ApprovalRequests
|
||||||
|
.Where(candidate => candidate.WorkflowInstanceId == workflowInstanceId && candidate.State == PendingState)
|
||||||
|
.OrderBy(candidate => candidate.WorkflowStepSortOrder)
|
||||||
|
.ThenBy(candidate => candidate.SentAt)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> CanApproveStepAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
ApprovalRequest approval,
|
||||||
|
Guid workspaceId,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
Guid userId = user.GetUserId();
|
||||||
|
bool hasWorkspaceAccess = await UserHasWorkspaceAccessAsync(userId, workspaceId, ct);
|
||||||
|
string[] userRoles = ApprovalStepConfigurationRules.AllowedRoleTargets
|
||||||
|
.Where(user.IsInRole)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return ApprovalWorkflowRules.CanApproveWorkflowStep(
|
||||||
|
user.IsInRole(KnownRoles.Administrator),
|
||||||
|
hasWorkspaceAccess,
|
||||||
|
userRoles,
|
||||||
|
userId,
|
||||||
|
approval.WorkflowStepTargetType,
|
||||||
|
approval.WorkflowStepTargetValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> UserHasWorkspaceAccessAsync(Guid userId, Guid workspaceId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
string workspaceClaimValue = workspaceId.ToString();
|
||||||
|
return await dbContext.UserClaims.AnyAsync(
|
||||||
|
claim => claim.UserId == userId &&
|
||||||
|
claim.ClaimType == KnownClaims.WorkspaceScope &&
|
||||||
|
claim.ClaimValue == workspaceClaimValue,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task NotifyCurrentStepApproversAsync(
|
||||||
|
ApprovalRequest approval,
|
||||||
|
ContentItem contentItem,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
List<ApprovalNotificationRecipient> recipients = await GetStepApproverRecipientsAsync(approval, ct);
|
||||||
|
|
||||||
|
foreach (ApprovalNotificationRecipient recipient in recipients)
|
||||||
|
{
|
||||||
|
await notificationEventWriter.WriteAsync(
|
||||||
|
new NotificationEventWriteModel(
|
||||||
|
approval.WorkspaceId,
|
||||||
|
approval.ContentItemId,
|
||||||
|
"approval.step.current",
|
||||||
|
"ApprovalRequest",
|
||||||
|
approval.Id,
|
||||||
|
$"{approval.Stage} approval is ready for {contentItem.Title}.",
|
||||||
|
recipient.UserId,
|
||||||
|
recipient.Email,
|
||||||
|
$$"""{"stage":"{{approval.Stage}}","requiredApproverCount":{{approval.WorkflowStepRequiredApproverCount ?? 1}}}"""),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task NotifyPublishUsersAsync(
|
||||||
|
ApprovalRequest approval,
|
||||||
|
ContentItem contentItem,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
List<ApprovalNotificationRecipient> recipients = await GetPublishRecipientUsersAsync(approval.WorkspaceId, ct);
|
||||||
|
|
||||||
|
foreach (ApprovalNotificationRecipient recipient in recipients)
|
||||||
|
{
|
||||||
|
await notificationEventWriter.WriteAsync(
|
||||||
|
new NotificationEventWriteModel(
|
||||||
|
approval.WorkspaceId,
|
||||||
|
approval.ContentItemId,
|
||||||
|
"approval.workflow.completed",
|
||||||
|
"ApprovalWorkflowInstance",
|
||||||
|
approval.WorkflowInstanceId!.Value,
|
||||||
|
$"Final approval completed for {contentItem.Title}.",
|
||||||
|
recipient.UserId,
|
||||||
|
recipient.Email,
|
||||||
|
$$"""{"status":"{{contentItem.Status}}"}"""),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<ApprovalNotificationRecipient>> GetStepApproverRecipientsAsync(
|
||||||
|
ApprovalRequest approval,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
string? targetType = approval.WorkflowStepTargetType;
|
||||||
|
string? targetValue = approval.WorkflowStepTargetValue;
|
||||||
|
if (string.IsNullOrWhiteSpace(targetType) || string.IsNullOrWhiteSpace(targetValue))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetType switch
|
||||||
|
{
|
||||||
|
ApprovalStepTargetTypes.Member => await GetMemberRecipientsAsync(targetValue, ct),
|
||||||
|
ApprovalStepTargetTypes.Role => await GetRoleRecipientsAsync(approval.WorkspaceId, [targetValue], ct),
|
||||||
|
ApprovalStepTargetTypes.Membership => await GetMembershipRecipientsAsync(approval.WorkspaceId, targetValue, ct),
|
||||||
|
_ => [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<ApprovalNotificationRecipient>> GetMemberRecipientsAsync(string targetValue, CancellationToken ct)
|
||||||
|
{
|
||||||
|
IReadOnlyCollection<Guid> userIds = ApprovalWorkflowRules.ParseMemberTargetIds(targetValue);
|
||||||
|
if (userIds.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await dbContext.Users
|
||||||
|
.Where(user => userIds.Contains(user.Id))
|
||||||
|
.Select(user => new ApprovalNotificationRecipient(user.Id, user.Email))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<ApprovalNotificationRecipient>> GetMembershipRecipientsAsync(
|
||||||
|
Guid workspaceId,
|
||||||
|
string targetValue,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
string[] roles = targetValue switch
|
||||||
|
{
|
||||||
|
ApprovalMembershipTargets.Client => [KnownRoles.Client],
|
||||||
|
ApprovalMembershipTargets.Team => [KnownRoles.Administrator, KnownRoles.Manager, KnownRoles.WorkspaceMember, KnownRoles.Provider],
|
||||||
|
_ => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return roles.Length == 0
|
||||||
|
? []
|
||||||
|
: await GetRoleRecipientsAsync(workspaceId, roles, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<ApprovalNotificationRecipient>> GetPublishRecipientUsersAsync(Guid workspaceId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await GetRoleRecipientsAsync(workspaceId, [KnownRoles.Administrator, KnownRoles.Manager], ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<ApprovalNotificationRecipient>> GetRoleRecipientsAsync(
|
||||||
|
Guid workspaceId,
|
||||||
|
IReadOnlyCollection<string> roles,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
string workspaceClaimValue = workspaceId.ToString();
|
||||||
|
|
||||||
|
return await dbContext.UserRoles
|
||||||
|
.Join(
|
||||||
|
dbContext.Roles,
|
||||||
|
userRole => userRole.RoleId,
|
||||||
|
role => role.Id,
|
||||||
|
(userRole, role) => new { userRole.UserId, RoleName = role.Name })
|
||||||
|
.Where(candidate => candidate.RoleName != null && roles.Contains(candidate.RoleName))
|
||||||
|
.Join(
|
||||||
|
dbContext.UserClaims.Where(claim =>
|
||||||
|
claim.ClaimType == KnownClaims.WorkspaceScope &&
|
||||||
|
claim.ClaimValue == workspaceClaimValue),
|
||||||
|
candidate => candidate.UserId,
|
||||||
|
claim => claim.UserId,
|
||||||
|
(candidate, _) => candidate.UserId)
|
||||||
|
.Distinct()
|
||||||
|
.Join(
|
||||||
|
dbContext.Users,
|
||||||
|
userId => userId,
|
||||||
|
user => user.Id,
|
||||||
|
(_, user) => new ApprovalNotificationRecipient(user.Id, user.Email))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatStepTarget(WorkspaceApprovalStepConfiguration step)
|
||||||
|
{
|
||||||
|
return step.TargetType switch
|
||||||
|
{
|
||||||
|
ApprovalStepTargetTypes.Role => $"Role: {step.TargetValue}",
|
||||||
|
ApprovalStepTargetTypes.Membership => $"Membership: {step.TargetValue}",
|
||||||
|
ApprovalStepTargetTypes.Member => "Assigned members",
|
||||||
|
_ => step.TargetValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateAccessToken()
|
||||||
|
{
|
||||||
|
return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record ApprovalNotificationRecipient(Guid UserId, string? Email);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Socialize.Modules.Assets.Data;
|
namespace Socialize.Api.Modules.Assets.Data;
|
||||||
|
|
||||||
public class Asset
|
public class Asset
|
||||||
{
|
{
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.Assets.Data;
|
||||||
|
|
||||||
|
public static class AssetModelConfiguration
|
||||||
|
{
|
||||||
|
public static ModelBuilder ConfigureAssetsModule(this ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
return modelBuilder;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Socialize.Modules.Assets.Data;
|
namespace Socialize.Api.Modules.Assets.Data;
|
||||||
|
|
||||||
public class AssetRevision
|
public class AssetRevision
|
||||||
{
|
{
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using Socialize.Modules.Assets.Data;
|
using Socialize.Api.Modules.Assets.Data;
|
||||||
|
|
||||||
namespace Socialize.Modules.Assets;
|
namespace Socialize.Api.Modules.Assets;
|
||||||
|
|
||||||
public static class DependencyInjection
|
public static class DependencyInjection
|
||||||
{
|
{
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
using Socialize.Infrastructure.Security;
|
using FastEndpoints;
|
||||||
using Socialize.Modules.Notifications.Contracts;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Socialize.Api.Modules.Assets.Data;
|
||||||
|
using Socialize.Api.Modules.ContentItems.Contracts;
|
||||||
|
using Socialize.Api.Modules.ContentItems.Data;
|
||||||
|
using Socialize.Api.Modules.Notifications.Contracts;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace Socialize.Modules.Assets.Handlers;
|
namespace Socialize.Api.Modules.Assets.Handlers;
|
||||||
|
|
||||||
public record CreateAssetRevisionRequest(
|
public record CreateAssetRevisionRequest(
|
||||||
string SourceReference,
|
string SourceReference,
|
||||||
@@ -22,6 +29,7 @@ public class CreateAssetRevisionRequestValidator
|
|||||||
public class CreateAssetRevisionHandler(
|
public class CreateAssetRevisionHandler(
|
||||||
AppDbContext dbContext,
|
AppDbContext dbContext,
|
||||||
AccessScopeService accessScopeService,
|
AccessScopeService accessScopeService,
|
||||||
|
IContentItemActivityWriter activityWriter,
|
||||||
INotificationEventWriter notificationEventWriter)
|
INotificationEventWriter notificationEventWriter)
|
||||||
: Endpoint<CreateAssetRevisionRequest, AssetRevisionDto>
|
: Endpoint<CreateAssetRevisionRequest, AssetRevisionDto>
|
||||||
{
|
{
|
||||||
@@ -46,7 +54,7 @@ public class CreateAssetRevisionHandler(
|
|||||||
.SingleOrDefaultAsync(candidate => candidate.Id == asset.ContentItemId, ct);
|
.SingleOrDefaultAsync(candidate => candidate.Id == asset.ContentItemId, ct);
|
||||||
|
|
||||||
if (contentItem is not null &&
|
if (contentItem is not null &&
|
||||||
!accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
|
!await accessScopeService.CanContributeToCampaignAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
|
||||||
{
|
{
|
||||||
await SendForbiddenAsync(ct);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
@@ -73,6 +81,25 @@ public class CreateAssetRevisionHandler(
|
|||||||
|
|
||||||
if (contentItem is not null)
|
if (contentItem is not null)
|
||||||
{
|
{
|
||||||
|
await activityWriter.WriteAsync(
|
||||||
|
new ContentItemActivityWriteModel(
|
||||||
|
asset.WorkspaceId,
|
||||||
|
asset.ContentItemId,
|
||||||
|
"asset.revision.created",
|
||||||
|
"AssetRevision",
|
||||||
|
revision.Id,
|
||||||
|
$"A new asset revision was added to {asset.DisplayName}.",
|
||||||
|
User.GetUserId(),
|
||||||
|
User.GetEmail(),
|
||||||
|
JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
assetId = asset.Id,
|
||||||
|
revisionNumber,
|
||||||
|
sourceReference = revision.SourceReference,
|
||||||
|
notes = revision.Notes,
|
||||||
|
})),
|
||||||
|
ct);
|
||||||
|
|
||||||
await notificationEventWriter.WriteAsync(
|
await notificationEventWriter.WriteAsync(
|
||||||
new NotificationEventWriteModel(
|
new NotificationEventWriteModel(
|
||||||
asset.WorkspaceId,
|
asset.WorkspaceId,
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
using Socialize.Infrastructure.Security;
|
using FastEndpoints;
|
||||||
using Socialize.Modules.Notifications.Contracts;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Socialize.Api.Modules.Assets.Data;
|
||||||
|
using Socialize.Api.Modules.ContentItems.Contracts;
|
||||||
|
using Socialize.Api.Modules.ContentItems.Data;
|
||||||
|
using Socialize.Api.Modules.Notifications.Contracts;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace Socialize.Modules.Assets.Handlers;
|
namespace Socialize.Api.Modules.Assets.Handlers;
|
||||||
|
|
||||||
public record CreateGoogleDriveAssetRequest(
|
public record CreateGoogleDriveAssetRequest(
|
||||||
Guid WorkspaceId,
|
Guid WorkspaceId,
|
||||||
@@ -30,6 +37,7 @@ public class CreateGoogleDriveAssetRequestValidator
|
|||||||
public class CreateGoogleDriveAssetHandler(
|
public class CreateGoogleDriveAssetHandler(
|
||||||
AppDbContext dbContext,
|
AppDbContext dbContext,
|
||||||
AccessScopeService accessScopeService,
|
AccessScopeService accessScopeService,
|
||||||
|
IContentItemActivityWriter activityWriter,
|
||||||
INotificationEventWriter notificationEventWriter)
|
INotificationEventWriter notificationEventWriter)
|
||||||
: Endpoint<CreateGoogleDriveAssetRequest, AssetDto>
|
: Endpoint<CreateGoogleDriveAssetRequest, AssetDto>
|
||||||
{
|
{
|
||||||
@@ -53,7 +61,7 @@ public class CreateGoogleDriveAssetHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
|
if (!await accessScopeService.CanContributeToCampaignAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
|
||||||
{
|
{
|
||||||
await SendForbiddenAsync(ct);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
@@ -88,6 +96,25 @@ public class CreateGoogleDriveAssetHandler(
|
|||||||
dbContext.AssetRevisions.Add(revision);
|
dbContext.AssetRevisions.Add(revision);
|
||||||
await dbContext.SaveChangesAsync(ct);
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
await activityWriter.WriteAsync(
|
||||||
|
new ContentItemActivityWriteModel(
|
||||||
|
asset.WorkspaceId,
|
||||||
|
asset.ContentItemId,
|
||||||
|
"asset.google-drive-linked",
|
||||||
|
"Asset",
|
||||||
|
asset.Id,
|
||||||
|
$"Google Drive asset {asset.DisplayName} was linked to {contentItem.Title}.",
|
||||||
|
User.GetUserId(),
|
||||||
|
User.GetEmail(),
|
||||||
|
JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
assetType = asset.AssetType,
|
||||||
|
sourceType = asset.SourceType,
|
||||||
|
googleDriveFileId = asset.GoogleDriveFileId,
|
||||||
|
currentRevisionNumber = asset.CurrentRevisionNumber,
|
||||||
|
})),
|
||||||
|
ct);
|
||||||
|
|
||||||
await notificationEventWriter.WriteAsync(
|
await notificationEventWriter.WriteAsync(
|
||||||
new NotificationEventWriteModel(
|
new NotificationEventWriteModel(
|
||||||
asset.WorkspaceId,
|
asset.WorkspaceId,
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
using Socialize.Infrastructure.Security;
|
using FastEndpoints;
|
||||||
namespace Socialize.Modules.Assets.Handlers;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.Assets.Handlers;
|
||||||
|
|
||||||
public record GetAssetsRequest(Guid ContentItemId);
|
public record GetAssetsRequest(Guid ContentItemId);
|
||||||
|
|
||||||
@@ -40,7 +44,7 @@ public class GetAssetsHandler(
|
|||||||
|
|
||||||
public override async Task HandleAsync(GetAssetsRequest request, CancellationToken ct)
|
public override async Task HandleAsync(GetAssetsRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
ContentItem? item = await dbContext.ContentItems
|
var item = await dbContext.ContentItems
|
||||||
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId, ct);
|
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId, ct);
|
||||||
if (item is null)
|
if (item is null)
|
||||||
{
|
{
|
||||||
@@ -48,7 +52,7 @@ public class GetAssetsHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId))
|
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
|
||||||
{
|
{
|
||||||
await SendForbiddenAsync(ct);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace Socialize.Api.Modules.CalendarIntegrations.Data;
|
||||||
|
|
||||||
|
public class CalendarCatalogEntry
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
public required string Title { get; set; }
|
||||||
|
public required string Description { get; set; }
|
||||||
|
public string? Country { get; set; }
|
||||||
|
public string? Region { get; set; }
|
||||||
|
public required string Language { get; set; }
|
||||||
|
public required string Category { get; set; }
|
||||||
|
public string? CultureOrReligion { get; set; }
|
||||||
|
public required string ProviderName { get; set; }
|
||||||
|
public required string SourceUrl { get; set; }
|
||||||
|
public required string TrustLevel { get; set; }
|
||||||
|
public required string DefaultColor { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
namespace Socialize.Api.Modules.CalendarIntegrations.Data;
|
||||||
|
|
||||||
|
public static class CalendarCatalogSeed
|
||||||
|
{
|
||||||
|
public static readonly CalendarCatalogEntry[] Entries =
|
||||||
|
[
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.Parse("10000000-0000-0000-0000-000000000001"),
|
||||||
|
Title = "United States Public Holidays",
|
||||||
|
Description = "Federal public holiday calendar for the United States.",
|
||||||
|
Country = "US",
|
||||||
|
Region = null,
|
||||||
|
Language = "en",
|
||||||
|
Category = "public-holiday",
|
||||||
|
CultureOrReligion = null,
|
||||||
|
ProviderName = "Nager.Date",
|
||||||
|
SourceUrl = "https://date.nager.at/api/v3/PublicHolidays/2026/US",
|
||||||
|
TrustLevel = "Verified",
|
||||||
|
DefaultColor = "#2F80ED",
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.Parse("10000000-0000-0000-0000-000000000002"),
|
||||||
|
Title = "Canada Public Holidays",
|
||||||
|
Description = "Public holiday calendar for Canada.",
|
||||||
|
Country = "CA",
|
||||||
|
Region = null,
|
||||||
|
Language = "en",
|
||||||
|
Category = "public-holiday",
|
||||||
|
CultureOrReligion = null,
|
||||||
|
ProviderName = "Nager.Date",
|
||||||
|
SourceUrl = "https://date.nager.at/api/v3/PublicHolidays/2026/CA",
|
||||||
|
TrustLevel = "Verified",
|
||||||
|
DefaultColor = "#2F80ED",
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.Parse("10000000-0000-0000-0000-000000000003"),
|
||||||
|
Title = "Common Marketing Moments",
|
||||||
|
Description = "Common retail, awareness, and social planning moments.",
|
||||||
|
Country = null,
|
||||||
|
Region = null,
|
||||||
|
Language = "en",
|
||||||
|
Category = "marketing-moment",
|
||||||
|
CultureOrReligion = null,
|
||||||
|
ProviderName = "Socialize",
|
||||||
|
SourceUrl = "https://example.com/socialize/marketing-moments.ics",
|
||||||
|
TrustLevel = "Maintained",
|
||||||
|
DefaultColor = "#9B51E0",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace Socialize.Api.Modules.CalendarIntegrations.Data;
|
||||||
|
|
||||||
|
public class CalendarEvent
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
public Guid CalendarSourceId { get; set; }
|
||||||
|
public required string SourceEventUid { get; set; }
|
||||||
|
public required string Title { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public bool IsAllDay { get; set; }
|
||||||
|
public bool IsFloatingTime { get; set; }
|
||||||
|
public DateOnly StartDate { get; set; }
|
||||||
|
public DateOnly EndDate { get; set; }
|
||||||
|
public DateTime? StartLocalDateTime { get; set; }
|
||||||
|
public DateTime? EndLocalDateTime { get; set; }
|
||||||
|
public DateTimeOffset? StartUtc { get; set; }
|
||||||
|
public DateTimeOffset? EndUtc { get; set; }
|
||||||
|
public string? TimeZoneId { get; set; }
|
||||||
|
public string? RecurrenceId { get; set; }
|
||||||
|
public string? Location { get; set; }
|
||||||
|
public string? SourceUrl { get; set; }
|
||||||
|
public DateTimeOffset? SourceLastModifiedAt { get; set; }
|
||||||
|
public DateTimeOffset ImportedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace Socialize.Api.Modules.CalendarIntegrations.Data;
|
||||||
|
|
||||||
|
public class CalendarSource
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
public required string Scope { get; set; }
|
||||||
|
public Guid? OrganizationId { get; set; }
|
||||||
|
public Guid? WorkspaceId { get; set; }
|
||||||
|
public Guid? UserId { get; set; }
|
||||||
|
public string? SourceUrl { get; set; }
|
||||||
|
public string? CatalogSourceReference { get; set; }
|
||||||
|
public required string DisplayTitle { get; set; }
|
||||||
|
public required string Color { get; set; }
|
||||||
|
public required string Category { get; set; }
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
public string? InheritanceMode { get; set; }
|
||||||
|
public DateTimeOffset? LastSuccessfulSyncAt { get; set; }
|
||||||
|
public DateTimeOffset? LastAttemptedSyncAt { get; set; }
|
||||||
|
public string? LastSyncError { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public DateTimeOffset UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.CalendarIntegrations.Data;
|
||||||
|
|
||||||
|
public static class CalendarSourceModelConfiguration
|
||||||
|
{
|
||||||
|
public static ModelBuilder ConfigureCalendarIntegrationsModule(this ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<CalendarSource>(source =>
|
||||||
|
{
|
||||||
|
source.ToTable("CalendarSources");
|
||||||
|
source.HasKey(x => x.Id);
|
||||||
|
source.Property(x => x.Scope).HasMaxLength(32).IsRequired();
|
||||||
|
source.Property(x => x.SourceUrl).HasMaxLength(2048);
|
||||||
|
source.Property(x => x.CatalogSourceReference).HasMaxLength(256);
|
||||||
|
source.Property(x => x.DisplayTitle).HasMaxLength(256).IsRequired();
|
||||||
|
source.Property(x => x.Color).HasMaxLength(16).IsRequired();
|
||||||
|
source.Property(x => x.Category).HasMaxLength(64).IsRequired();
|
||||||
|
source.Property(x => x.InheritanceMode).HasMaxLength(32);
|
||||||
|
source.Property(x => x.LastSyncError).HasMaxLength(2048);
|
||||||
|
source.Property(x => x.CreatedAt)
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
source.Property(x => x.UpdatedAt)
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
|
||||||
|
source.HasIndex(x => x.Scope);
|
||||||
|
source.HasIndex(x => x.OrganizationId);
|
||||||
|
source.HasIndex(x => x.WorkspaceId);
|
||||||
|
source.HasIndex(x => x.UserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<CalendarCatalogEntry>(entry =>
|
||||||
|
{
|
||||||
|
entry.ToTable("CalendarCatalogEntries");
|
||||||
|
entry.HasKey(x => x.Id);
|
||||||
|
entry.Property(x => x.Title).HasMaxLength(256).IsRequired();
|
||||||
|
entry.Property(x => x.Description).HasMaxLength(1024).IsRequired();
|
||||||
|
entry.Property(x => x.Country).HasMaxLength(2);
|
||||||
|
entry.Property(x => x.Region).HasMaxLength(128);
|
||||||
|
entry.Property(x => x.Language).HasMaxLength(16).IsRequired();
|
||||||
|
entry.Property(x => x.Category).HasMaxLength(64).IsRequired();
|
||||||
|
entry.Property(x => x.CultureOrReligion).HasMaxLength(128);
|
||||||
|
entry.Property(x => x.ProviderName).HasMaxLength(128).IsRequired();
|
||||||
|
entry.Property(x => x.SourceUrl).HasMaxLength(2048).IsRequired();
|
||||||
|
entry.Property(x => x.TrustLevel).HasMaxLength(64).IsRequired();
|
||||||
|
entry.Property(x => x.DefaultColor).HasMaxLength(16).IsRequired();
|
||||||
|
entry.Property(x => x.CreatedAt)
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
entry.HasIndex(x => x.Country);
|
||||||
|
entry.HasIndex(x => x.Category);
|
||||||
|
entry.HasIndex(x => x.ProviderName);
|
||||||
|
entry.HasData(CalendarCatalogSeed.Entries);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<CalendarEvent>(calendarEvent =>
|
||||||
|
{
|
||||||
|
calendarEvent.ToTable("CalendarEvents");
|
||||||
|
calendarEvent.HasKey(x => x.Id);
|
||||||
|
calendarEvent.Property(x => x.SourceEventUid).HasMaxLength(512).IsRequired();
|
||||||
|
calendarEvent.Property(x => x.Title).HasMaxLength(512).IsRequired();
|
||||||
|
calendarEvent.Property(x => x.Description).HasMaxLength(4000);
|
||||||
|
calendarEvent.Property(x => x.TimeZoneId).HasMaxLength(128);
|
||||||
|
calendarEvent.Property(x => x.RecurrenceId).HasMaxLength(512);
|
||||||
|
calendarEvent.Property(x => x.Location).HasMaxLength(512);
|
||||||
|
calendarEvent.Property(x => x.SourceUrl).HasMaxLength(2048);
|
||||||
|
calendarEvent.HasIndex(x => x.CalendarSourceId);
|
||||||
|
calendarEvent.HasIndex(x => new { x.CalendarSourceId, x.SourceEventUid, x.StartDate }).IsUnique();
|
||||||
|
calendarEvent.HasOne<CalendarSource>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.CalendarSourceId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<UserCalendarExportFeed>(feed =>
|
||||||
|
{
|
||||||
|
feed.ToTable("UserCalendarExportFeeds");
|
||||||
|
feed.HasKey(x => x.Id);
|
||||||
|
feed.Property(x => x.Token).HasMaxLength(96);
|
||||||
|
feed.Property(x => x.TokenHash).HasMaxLength(64);
|
||||||
|
feed.Property(x => x.CreatedAt)
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
feed.Property(x => x.UpdatedAt)
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
feed.HasIndex(x => x.UserId).IsUnique();
|
||||||
|
feed.HasIndex(x => x.TokenHash).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
|
return modelBuilder;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Socialize.Api.Modules.CalendarIntegrations.Data;
|
||||||
|
|
||||||
|
public class UserCalendarExportFeed
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public string? Token { get; set; }
|
||||||
|
public string? TokenHash { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public DateTimeOffset UpdatedAt { get; set; }
|
||||||
|
public DateTimeOffset? RevokedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Socialize.Api.Modules.CalendarIntegrations;
|
||||||
|
|
||||||
|
public static class DependencyInjection
|
||||||
|
{
|
||||||
|
public static WebApplicationBuilder AddCalendarIntegrationsModule(this WebApplicationBuilder builder)
|
||||||
|
{
|
||||||
|
builder.Services.AddSingleton<Services.IcsCalendarParser>();
|
||||||
|
builder.Services.AddSingleton<Services.CalendarExportFeedBuilder>();
|
||||||
|
builder.Services.AddScoped<Services.CalendarExportFeedService>();
|
||||||
|
builder.Services.AddScoped<Services.CalendarImportSyncService>();
|
||||||
|
builder.Services.AddHostedService<Services.CalendarImportBackgroundService>();
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using Socialize.Api.Modules.CalendarIntegrations.Data;
|
||||||
|
using Socialize.Api.Modules.CalendarIntegrations.Services;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
|
||||||
|
|
||||||
|
public record CalendarSourceDto(
|
||||||
|
Guid Id,
|
||||||
|
string Scope,
|
||||||
|
Guid? OrganizationId,
|
||||||
|
Guid? WorkspaceId,
|
||||||
|
Guid? UserId,
|
||||||
|
string? SourceUrl,
|
||||||
|
string? CatalogSourceReference,
|
||||||
|
string DisplayTitle,
|
||||||
|
string Color,
|
||||||
|
string Category,
|
||||||
|
bool IsEnabled,
|
||||||
|
string? InheritanceMode,
|
||||||
|
bool IsReadOnly,
|
||||||
|
DateTimeOffset? LastSuccessfulSyncAt,
|
||||||
|
DateTimeOffset? LastAttemptedSyncAt,
|
||||||
|
string? LastSyncError,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
DateTimeOffset UpdatedAt)
|
||||||
|
{
|
||||||
|
public static CalendarSourceDto FromSource(CalendarSource source, bool isReadOnly)
|
||||||
|
{
|
||||||
|
return new CalendarSourceDto(
|
||||||
|
source.Id,
|
||||||
|
source.Scope,
|
||||||
|
source.OrganizationId,
|
||||||
|
source.WorkspaceId,
|
||||||
|
source.UserId,
|
||||||
|
source.SourceUrl,
|
||||||
|
source.CatalogSourceReference,
|
||||||
|
source.DisplayTitle,
|
||||||
|
source.Color,
|
||||||
|
source.Category,
|
||||||
|
source.IsEnabled,
|
||||||
|
source.InheritanceMode,
|
||||||
|
isReadOnly,
|
||||||
|
source.LastSuccessfulSyncAt,
|
||||||
|
source.LastAttemptedSyncAt,
|
||||||
|
source.LastSyncError,
|
||||||
|
source.CreatedAt,
|
||||||
|
source.UpdatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UpsertCalendarSourceRequest(
|
||||||
|
string Scope,
|
||||||
|
Guid? OrganizationId,
|
||||||
|
Guid? WorkspaceId,
|
||||||
|
string? SourceUrl,
|
||||||
|
string? CatalogSourceReference,
|
||||||
|
string DisplayTitle,
|
||||||
|
string Color,
|
||||||
|
string Category,
|
||||||
|
bool IsEnabled,
|
||||||
|
string? InheritanceMode);
|
||||||
|
|
||||||
|
public class UpsertCalendarSourceRequestValidator
|
||||||
|
: FastEndpoints.Validator<UpsertCalendarSourceRequest>
|
||||||
|
{
|
||||||
|
public UpsertCalendarSourceRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Scope)
|
||||||
|
.NotEmpty()
|
||||||
|
.Must(CalendarSourceRules.IsSupportedScope)
|
||||||
|
.WithMessage("A valid calendar source scope should be specified.");
|
||||||
|
|
||||||
|
RuleFor(x => x.DisplayTitle).NotEmpty().MaximumLength(256);
|
||||||
|
RuleFor(x => x.Category).NotEmpty().MaximumLength(64);
|
||||||
|
RuleFor(x => x.Color)
|
||||||
|
.NotEmpty()
|
||||||
|
.Matches("^#[0-9A-Fa-f]{6}$")
|
||||||
|
.WithMessage("Color should be a six digit hex color, for example #2F80ED.");
|
||||||
|
|
||||||
|
RuleFor(x => x.SourceUrl)
|
||||||
|
.MaximumLength(2048)
|
||||||
|
.Must(value => string.IsNullOrWhiteSpace(value) || Uri.TryCreate(value, UriKind.Absolute, out Uri? uri) &&
|
||||||
|
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
|
||||||
|
.WithMessage("Source URL should be an absolute HTTP or HTTPS URL.");
|
||||||
|
|
||||||
|
RuleFor(x => x.CatalogSourceReference).MaximumLength(256);
|
||||||
|
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => !string.IsNullOrWhiteSpace(x.SourceUrl) || !string.IsNullOrWhiteSpace(x.CatalogSourceReference))
|
||||||
|
.WithMessage("A source URL or catalog source reference should be specified.");
|
||||||
|
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => x.Scope != CalendarSourceScopes.Organization || x.OrganizationId.HasValue)
|
||||||
|
.WithMessage("Organization calendar sources require an organization id.");
|
||||||
|
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => x.Scope != CalendarSourceScopes.Workspace || x.WorkspaceId.HasValue)
|
||||||
|
.WithMessage("Workspace calendar sources require a workspace id.");
|
||||||
|
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => x.Scope != CalendarSourceScopes.User || (!x.OrganizationId.HasValue && !x.WorkspaceId.HasValue))
|
||||||
|
.WithMessage("User calendar sources should not include organization or workspace ids.");
|
||||||
|
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => x.Scope == CalendarSourceScopes.Organization ||
|
||||||
|
(!x.OrganizationId.HasValue && string.IsNullOrWhiteSpace(x.InheritanceMode)))
|
||||||
|
.WithMessage("Only organization calendar sources can set organization ids or inheritance modes.");
|
||||||
|
|
||||||
|
RuleFor(x => x.InheritanceMode)
|
||||||
|
.Must(value => string.IsNullOrWhiteSpace(value) || CalendarSourceRules.IsSupportedInheritanceMode(value))
|
||||||
|
.WithMessage("A valid inheritance mode should be specified.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Socialize.Api.Modules.CalendarIntegrations.Data;
|
||||||
|
using Socialize.Api.Modules.CalendarIntegrations.Services;
|
||||||
|
using Socialize.Api.Modules.Organizations.Services;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
|
||||||
|
|
||||||
|
public class CreateCalendarSourceHandler(
|
||||||
|
AppDbContext dbContext,
|
||||||
|
AccessScopeService accessScopeService,
|
||||||
|
OrganizationAccessService organizationAccessService)
|
||||||
|
: Endpoint<UpsertCalendarSourceRequest, CalendarSourceDto>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/api/calendar-integrations/sources");
|
||||||
|
Options(o => o.WithTags("Calendar Integrations"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(UpsertCalendarSourceRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
Guid currentUserId = User.GetUserId();
|
||||||
|
string scope = request.Scope.Trim();
|
||||||
|
Guid? organizationId = request.OrganizationId;
|
||||||
|
Guid? workspaceId = request.WorkspaceId;
|
||||||
|
|
||||||
|
if (!await CanCreateAsync(scope, organizationId, workspaceId, currentUserId, ct))
|
||||||
|
{
|
||||||
|
await SendForbiddenAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? sourceUrl = NormalizeOptional(request.SourceUrl);
|
||||||
|
string? catalogSourceReference = NormalizeOptional(request.CatalogSourceReference);
|
||||||
|
if (await SourceAlreadyExistsAsync(scope, organizationId, workspaceId, currentUserId, sourceUrl, catalogSourceReference, ct))
|
||||||
|
{
|
||||||
|
AddError(request => request.SourceUrl, "This calendar source has already been added.");
|
||||||
|
await SendErrorsAsync(cancellation: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CalendarSource source = new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Scope = scope,
|
||||||
|
OrganizationId = scope == CalendarSourceScopes.Organization ? organizationId : null,
|
||||||
|
WorkspaceId = scope == CalendarSourceScopes.Workspace ? workspaceId : null,
|
||||||
|
UserId = scope == CalendarSourceScopes.User ? currentUserId : null,
|
||||||
|
SourceUrl = sourceUrl,
|
||||||
|
CatalogSourceReference = catalogSourceReference,
|
||||||
|
DisplayTitle = request.DisplayTitle.Trim(),
|
||||||
|
Color = request.Color.Trim(),
|
||||||
|
Category = request.Category.Trim(),
|
||||||
|
IsEnabled = request.IsEnabled,
|
||||||
|
InheritanceMode = scope == CalendarSourceScopes.Organization
|
||||||
|
? NormalizeOptional(request.InheritanceMode) ?? CalendarSourceInheritanceModes.Optional
|
||||||
|
: null,
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
dbContext.CalendarSources.Add(source);
|
||||||
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
await SendAsync(CalendarSourceDto.FromSource(source, isReadOnly: false), StatusCodes.Status201Created, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> CanCreateAsync(
|
||||||
|
string scope,
|
||||||
|
Guid? organizationId,
|
||||||
|
Guid? workspaceId,
|
||||||
|
Guid currentUserId,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
return scope switch
|
||||||
|
{
|
||||||
|
CalendarSourceScopes.Organization when organizationId.HasValue =>
|
||||||
|
await dbContext.Organizations.AnyAsync(organization => organization.Id == organizationId.Value, ct) &&
|
||||||
|
await organizationAccessService.HasOrganizationPermissionAsync(
|
||||||
|
User,
|
||||||
|
organizationId.Value,
|
||||||
|
OrganizationPermissions.ManageConnectors,
|
||||||
|
ct),
|
||||||
|
CalendarSourceScopes.Workspace when workspaceId.HasValue =>
|
||||||
|
await dbContext.Workspaces.AnyAsync(workspace => workspace.Id == workspaceId.Value, ct) &&
|
||||||
|
await accessScopeService.CanManageWorkspaceAsync(User, workspaceId.Value, ct),
|
||||||
|
CalendarSourceScopes.User => currentUserId != Guid.Empty,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<bool> SourceAlreadyExistsAsync(
|
||||||
|
string scope,
|
||||||
|
Guid? organizationId,
|
||||||
|
Guid? workspaceId,
|
||||||
|
Guid currentUserId,
|
||||||
|
string? sourceUrl,
|
||||||
|
string? catalogSourceReference,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
IQueryable<CalendarSource> query = dbContext.CalendarSources
|
||||||
|
.Where(source => source.Scope == scope);
|
||||||
|
|
||||||
|
query = scope switch
|
||||||
|
{
|
||||||
|
CalendarSourceScopes.Organization => query.Where(source => source.OrganizationId == organizationId),
|
||||||
|
CalendarSourceScopes.Workspace => query.Where(source => source.WorkspaceId == workspaceId),
|
||||||
|
CalendarSourceScopes.User => query.Where(source => source.UserId == currentUserId),
|
||||||
|
_ => query.Where(_ => false),
|
||||||
|
};
|
||||||
|
|
||||||
|
string? normalizedUrl = sourceUrl?.Trim();
|
||||||
|
string? normalizedCatalogReference = catalogSourceReference?.Trim();
|
||||||
|
|
||||||
|
return query.AnyAsync(source =>
|
||||||
|
(!string.IsNullOrWhiteSpace(normalizedCatalogReference) &&
|
||||||
|
source.CatalogSourceReference == normalizedCatalogReference) ||
|
||||||
|
(!string.IsNullOrWhiteSpace(normalizedUrl) &&
|
||||||
|
source.SourceUrl != null &&
|
||||||
|
source.SourceUrl.ToUpper() == normalizedUrl.ToUpper()),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeOptional(string? value)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Socialize.Api.Modules.CalendarIntegrations.Data;
|
||||||
|
using Socialize.Api.Modules.CalendarIntegrations.Services;
|
||||||
|
using Socialize.Api.Modules.Organizations.Services;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
|
||||||
|
|
||||||
|
public class DeleteCalendarSourceHandler(
|
||||||
|
AppDbContext dbContext,
|
||||||
|
AccessScopeService accessScopeService,
|
||||||
|
OrganizationAccessService organizationAccessService)
|
||||||
|
: EndpointWithoutRequest
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Delete("/api/calendar-integrations/sources/{sourceId:guid}");
|
||||||
|
Options(o => o.WithTags("Calendar Integrations"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
Guid sourceId = Route<Guid>("sourceId");
|
||||||
|
CalendarSource? source = await dbContext.CalendarSources.SingleOrDefaultAsync(candidate => candidate.Id == sourceId, ct);
|
||||||
|
if (source is null)
|
||||||
|
{
|
||||||
|
await SendNotFoundAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await CanManageExistingSourceAsync(source, User.GetUserId(), ct))
|
||||||
|
{
|
||||||
|
await SendForbiddenAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dbContext.CalendarSources.Remove(source);
|
||||||
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
await SendNoContentAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> CanManageExistingSourceAsync(
|
||||||
|
CalendarSource source,
|
||||||
|
Guid currentUserId,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
return source.Scope switch
|
||||||
|
{
|
||||||
|
CalendarSourceScopes.Organization when source.OrganizationId.HasValue =>
|
||||||
|
await organizationAccessService.HasOrganizationPermissionAsync(
|
||||||
|
User,
|
||||||
|
source.OrganizationId.Value,
|
||||||
|
OrganizationPermissions.ManageConnectors,
|
||||||
|
ct),
|
||||||
|
CalendarSourceScopes.Workspace when source.WorkspaceId.HasValue =>
|
||||||
|
await accessScopeService.CanManageWorkspaceAsync(User, source.WorkspaceId.Value, ct),
|
||||||
|
CalendarSourceScopes.User => source.UserId == currentUserId,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Modules.CalendarIntegrations.Data;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
|
||||||
|
|
||||||
|
public sealed class ListCalendarCatalogRequest
|
||||||
|
{
|
||||||
|
public string? Search { get; set; }
|
||||||
|
public string? Country { get; set; }
|
||||||
|
public string? Region { get; set; }
|
||||||
|
public string? Language { get; set; }
|
||||||
|
public string? Category { get; set; }
|
||||||
|
public string? CultureOrReligion { get; set; }
|
||||||
|
public string? Provider { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CalendarCatalogEntryDto(
|
||||||
|
Guid Id,
|
||||||
|
string Title,
|
||||||
|
string Description,
|
||||||
|
string? Country,
|
||||||
|
string? Region,
|
||||||
|
string Language,
|
||||||
|
string Category,
|
||||||
|
string? CultureOrReligion,
|
||||||
|
string ProviderName,
|
||||||
|
string SourceUrl,
|
||||||
|
string TrustLevel,
|
||||||
|
string DefaultColor);
|
||||||
|
|
||||||
|
public class ListCalendarCatalogHandler(AppDbContext dbContext)
|
||||||
|
: Endpoint<ListCalendarCatalogRequest, IReadOnlyCollection<CalendarCatalogEntryDto>>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Get("/api/calendar-integrations/catalog");
|
||||||
|
Options(o => o.WithTags("Calendar Integrations"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(ListCalendarCatalogRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
IQueryable<CalendarCatalogEntry> query = dbContext.CalendarCatalogEntries.AsQueryable();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Search))
|
||||||
|
{
|
||||||
|
string search = request.Search.Trim().ToLowerInvariant();
|
||||||
|
query = query.Where(entry =>
|
||||||
|
entry.Title.ToLower().Contains(search) ||
|
||||||
|
entry.Description.ToLower().Contains(search) ||
|
||||||
|
entry.ProviderName.ToLower().Contains(search));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Country))
|
||||||
|
{
|
||||||
|
string country = request.Country.Trim().ToUpperInvariant();
|
||||||
|
query = query.Where(entry => entry.Country == country);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Region))
|
||||||
|
{
|
||||||
|
string region = request.Region.Trim();
|
||||||
|
query = query.Where(entry => entry.Region == region);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Language))
|
||||||
|
{
|
||||||
|
string language = request.Language.Trim();
|
||||||
|
query = query.Where(entry => entry.Language == language);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Category))
|
||||||
|
{
|
||||||
|
string category = request.Category.Trim();
|
||||||
|
query = query.Where(entry => entry.Category == category);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.CultureOrReligion))
|
||||||
|
{
|
||||||
|
string cultureOrReligion = request.CultureOrReligion.Trim();
|
||||||
|
query = query.Where(entry => entry.CultureOrReligion == cultureOrReligion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Provider))
|
||||||
|
{
|
||||||
|
string provider = request.Provider.Trim();
|
||||||
|
query = query.Where(entry => entry.ProviderName == provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
CalendarCatalogEntryDto[] entries = await query
|
||||||
|
.OrderBy(entry => entry.Country)
|
||||||
|
.ThenBy(entry => entry.Category)
|
||||||
|
.ThenBy(entry => entry.Title)
|
||||||
|
.Take(100)
|
||||||
|
.Select(entry => new CalendarCatalogEntryDto(
|
||||||
|
entry.Id,
|
||||||
|
entry.Title,
|
||||||
|
entry.Description,
|
||||||
|
entry.Country,
|
||||||
|
entry.Region,
|
||||||
|
entry.Language,
|
||||||
|
entry.Category,
|
||||||
|
entry.CultureOrReligion,
|
||||||
|
entry.ProviderName,
|
||||||
|
entry.SourceUrl,
|
||||||
|
entry.TrustLevel,
|
||||||
|
entry.DefaultColor))
|
||||||
|
.ToArrayAsync(ct);
|
||||||
|
|
||||||
|
await SendOkAsync(entries, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Socialize.Api.Modules.CalendarIntegrations.Data;
|
||||||
|
using Socialize.Api.Modules.CalendarIntegrations.Services;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
|
||||||
|
|
||||||
|
public sealed class ListCalendarEventsRequest
|
||||||
|
{
|
||||||
|
public Guid? WorkspaceId { get; set; }
|
||||||
|
public DateOnly? StartDate { get; set; }
|
||||||
|
public DateOnly? EndDate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CalendarEventDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid CalendarSourceId,
|
||||||
|
string SourceEventUid,
|
||||||
|
string Title,
|
||||||
|
string? Description,
|
||||||
|
bool IsAllDay,
|
||||||
|
bool IsFloatingTime,
|
||||||
|
DateOnly StartDate,
|
||||||
|
DateOnly EndDate,
|
||||||
|
DateTime? StartLocalDateTime,
|
||||||
|
DateTime? EndLocalDateTime,
|
||||||
|
DateTimeOffset? StartUtc,
|
||||||
|
DateTimeOffset? EndUtc,
|
||||||
|
string? TimeZoneId,
|
||||||
|
string? RecurrenceId,
|
||||||
|
string? Location,
|
||||||
|
string? SourceUrl,
|
||||||
|
DateTimeOffset? SourceLastModifiedAt,
|
||||||
|
DateTimeOffset ImportedAt);
|
||||||
|
|
||||||
|
public class ListCalendarEventsHandler(
|
||||||
|
AppDbContext dbContext,
|
||||||
|
AccessScopeService accessScopeService)
|
||||||
|
: Endpoint<ListCalendarEventsRequest, IReadOnlyCollection<CalendarEventDto>>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Get("/api/calendar-integrations/events");
|
||||||
|
Options(o => o.WithTags("Calendar Integrations"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(ListCalendarEventsRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
Guid currentUserId = User.GetUserId();
|
||||||
|
DateOnly startDate = request.StartDate ?? DateOnly.FromDateTime(DateTime.UtcNow.Date.AddMonths(-1));
|
||||||
|
DateOnly endDate = request.EndDate ?? DateOnly.FromDateTime(DateTime.UtcNow.Date.AddMonths(3));
|
||||||
|
|
||||||
|
if (request.WorkspaceId.HasValue &&
|
||||||
|
!await accessScopeService.CanAccessWorkspaceAsync(User, request.WorkspaceId.Value, ct))
|
||||||
|
{
|
||||||
|
await SendForbiddenAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IQueryable<CalendarSource> visibleSources = dbContext.CalendarSources
|
||||||
|
.Where(source => source.IsEnabled);
|
||||||
|
|
||||||
|
if (request.WorkspaceId.HasValue)
|
||||||
|
{
|
||||||
|
Guid? organizationId = await dbContext.Workspaces
|
||||||
|
.Where(workspace => workspace.Id == request.WorkspaceId.Value)
|
||||||
|
.Select(workspace => (Guid?)workspace.OrganizationId)
|
||||||
|
.SingleOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
if (!organizationId.HasValue)
|
||||||
|
{
|
||||||
|
await SendNotFoundAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleSources = visibleSources.Where(source =>
|
||||||
|
source.Scope == CalendarSourceScopes.Organization && source.OrganizationId == organizationId ||
|
||||||
|
source.Scope == CalendarSourceScopes.Workspace && source.WorkspaceId == request.WorkspaceId ||
|
||||||
|
source.Scope == CalendarSourceScopes.User && source.UserId == currentUserId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
IReadOnlyCollection<Guid> workspaceIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
|
||||||
|
Guid[] organizationIds = await dbContext.Workspaces
|
||||||
|
.Where(workspace => workspaceIds.Contains(workspace.Id))
|
||||||
|
.Select(workspace => workspace.OrganizationId)
|
||||||
|
.Distinct()
|
||||||
|
.ToArrayAsync(ct);
|
||||||
|
|
||||||
|
visibleSources = visibleSources.Where(source =>
|
||||||
|
source.Scope == CalendarSourceScopes.Organization && source.OrganizationId.HasValue && organizationIds.Contains(source.OrganizationId.Value) ||
|
||||||
|
source.Scope == CalendarSourceScopes.Workspace && source.WorkspaceId.HasValue && workspaceIds.Contains(source.WorkspaceId.Value) ||
|
||||||
|
source.Scope == CalendarSourceScopes.User && source.UserId == currentUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Guid[] sourceIds = await visibleSources
|
||||||
|
.Select(source => source.Id)
|
||||||
|
.ToArrayAsync(ct);
|
||||||
|
|
||||||
|
CalendarEventDto[] events = await dbContext.CalendarEvents
|
||||||
|
.Where(calendarEvent => sourceIds.Contains(calendarEvent.CalendarSourceId))
|
||||||
|
.Where(calendarEvent => calendarEvent.StartDate <= endDate && calendarEvent.EndDate >= startDate)
|
||||||
|
.OrderBy(calendarEvent => calendarEvent.StartDate)
|
||||||
|
.ThenBy(calendarEvent => calendarEvent.Title)
|
||||||
|
.Select(calendarEvent => new CalendarEventDto(
|
||||||
|
calendarEvent.Id,
|
||||||
|
calendarEvent.CalendarSourceId,
|
||||||
|
calendarEvent.SourceEventUid,
|
||||||
|
calendarEvent.Title,
|
||||||
|
calendarEvent.Description,
|
||||||
|
calendarEvent.IsAllDay,
|
||||||
|
calendarEvent.IsFloatingTime,
|
||||||
|
calendarEvent.StartDate,
|
||||||
|
calendarEvent.EndDate,
|
||||||
|
calendarEvent.StartLocalDateTime,
|
||||||
|
calendarEvent.EndLocalDateTime,
|
||||||
|
calendarEvent.StartUtc,
|
||||||
|
calendarEvent.EndUtc,
|
||||||
|
calendarEvent.TimeZoneId,
|
||||||
|
calendarEvent.RecurrenceId,
|
||||||
|
calendarEvent.Location,
|
||||||
|
calendarEvent.SourceUrl,
|
||||||
|
calendarEvent.SourceLastModifiedAt,
|
||||||
|
calendarEvent.ImportedAt))
|
||||||
|
.ToArrayAsync(ct);
|
||||||
|
|
||||||
|
await SendOkAsync(events, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Socialize.Api.Modules.CalendarIntegrations.Data;
|
||||||
|
using Socialize.Api.Modules.CalendarIntegrations.Services;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.CalendarIntegrations.Handlers;
|
||||||
|
|
||||||
|
public record ListCalendarSourcesRequest(Guid? WorkspaceId);
|
||||||
|
|
||||||
|
public class ListCalendarSourcesHandler(
|
||||||
|
AppDbContext dbContext,
|
||||||
|
AccessScopeService accessScopeService)
|
||||||
|
: Endpoint<ListCalendarSourcesRequest, IReadOnlyCollection<CalendarSourceDto>>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Get("/api/calendar-integrations/sources");
|
||||||
|
Options(o => o.WithTags("Calendar Integrations"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(ListCalendarSourcesRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
Guid currentUserId = User.GetUserId();
|
||||||
|
List<CalendarSource> sources;
|
||||||
|
|
||||||
|
if (request.WorkspaceId.HasValue)
|
||||||
|
{
|
||||||
|
var workspace = await dbContext.Workspaces
|
||||||
|
.Where(candidate => candidate.Id == request.WorkspaceId.Value)
|
||||||
|
.Select(candidate => new { candidate.Id, candidate.OrganizationId })
|
||||||
|
.SingleOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
if (workspace is null)
|
||||||
|
{
|
||||||
|
await SendNotFoundAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await accessScopeService.CanAccessWorkspaceAsync(User, workspace.Id, ct))
|
||||||
|
{
|
||||||
|
await SendForbiddenAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sources = await dbContext.CalendarSources
|
||||||
|
.Where(source =>
|
||||||
|
source.Scope == CalendarSourceScopes.Organization && source.OrganizationId == workspace.OrganizationId ||
|
||||||
|
source.Scope == CalendarSourceScopes.Workspace && source.WorkspaceId == workspace.Id ||
|
||||||
|
source.Scope == CalendarSourceScopes.User && source.UserId == currentUserId)
|
||||||
|
.OrderBy(source => source.Scope)
|
||||||
|
.ThenBy(source => source.DisplayTitle)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
await SendOkAsync(
|
||||||
|
sources
|
||||||
|
.Select(source => CalendarSourceDto.FromSource(
|
||||||
|
source,
|
||||||
|
CalendarSourceRules.IsInheritedOrganizationSource(source, workspace.OrganizationId)))
|
||||||
|
.ToArray(),
|
||||||
|
ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sources = await dbContext.CalendarSources
|
||||||
|
.Where(source => source.Scope == CalendarSourceScopes.User && source.UserId == currentUserId)
|
||||||
|
.OrderBy(source => source.DisplayTitle)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
await SendOkAsync(
|
||||||
|
sources.Select(source => CalendarSourceDto.FromSource(source, isReadOnly: false)).ToArray(),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user